From aef8e840481ad80c0fe26b5239354310bea7f6e8 Mon Sep 17 00:00:00 2001 From: Markus Thielker Date: Mon, 9 Dec 2024 01:01:43 +0100 Subject: [PATCH] NORY-22: add quick actions to identity page --- dashboard/src/app/user/[id]/page.tsx | 40 +++ dashboard/src/app/user/[id]/sessions/page.tsx | 32 +++ dashboard/src/app/user/action.ts | 66 +++++ dashboard/src/app/user/data-table.tsx | 236 ++++++++++++++++-- dashboard/src/app/user/page.tsx | 2 +- dashboard/src/components/ui/spinner.tsx | 2 +- 6 files changed, 360 insertions(+), 18 deletions(-) create mode 100644 dashboard/src/app/user/[id]/page.tsx create mode 100644 dashboard/src/app/user/[id]/sessions/page.tsx create mode 100644 dashboard/src/app/user/action.ts diff --git a/dashboard/src/app/user/[id]/page.tsx b/dashboard/src/app/user/[id]/page.tsx new file mode 100644 index 0000000..7b21fde --- /dev/null +++ b/dashboard/src/app/user/[id]/page.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { getIdentityApi } from '@/ory/sdk/server'; +import { ErrorDisplay } from '@/components/error'; + +export default async function UserDetailsPage({ params }: { params: Promise<{ id: string }> }) { + + const identityId = (await params).id; + + console.log('Loading identity', identityId); + + const identityApi = await getIdentityApi(); + const identity = await identityApi.getIdentity({ id: identityId }) + .then((response) => response.data) + .catch(() => { + console.log('Identity not found'); + }); + + if (!identity) { + return ; + } + + if (!identity.verifiable_addresses || !identity.verifiable_addresses[0]) { + return ; + } + + const address = identity.verifiable_addresses[0]; + + return ( +
+
+

{address.value}

+

{identity.id}

+
+
+ ); +} diff --git a/dashboard/src/app/user/[id]/sessions/page.tsx b/dashboard/src/app/user/[id]/sessions/page.tsx new file mode 100644 index 0000000..2f1ad70 --- /dev/null +++ b/dashboard/src/app/user/[id]/sessions/page.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { getIdentityApi } from '@/ory/sdk/server'; +import { ErrorDisplay } from '@/components/error'; + +export default async function UserDetailsPage({ params }: { params: Promise<{ id: string }> }) { + + const identityId = (await params).id; + + console.log('Loading identity', identityId); + + const identityApi = await getIdentityApi(); + const sessions = await identityApi.listIdentitySessions({ id: identityId }) + .then((response) => response.data) + .catch(() => { + console.log('Identity not found'); + }); + + if (!sessions) { + return ; + } + + return ( +
+
+

Sessions

+

These are all active sessions of the identity

+
+
+ ); +} diff --git a/dashboard/src/app/user/action.ts b/dashboard/src/app/user/action.ts new file mode 100644 index 0000000..4c393cc --- /dev/null +++ b/dashboard/src/app/user/action.ts @@ -0,0 +1,66 @@ +'use server'; + +import { getIdentityApi } from '@/ory/sdk/server'; +import { revalidatePath } from 'next/cache'; + +interface IdentityIdProps { + id: string; +} + +export async function deleteIdentitySessions({ id }: IdentityIdProps) { + + const identityApi = await getIdentityApi(); + const { data } = await identityApi.deleteIdentitySessions({ id }); + + console.log('Deleted identity\'s sessions', data); + + return data; +} + +export async function blockIdentity({ id }: IdentityIdProps) { + + const identityApi = await getIdentityApi(); + const { data } = await identityApi.patchIdentity({ + id, + jsonPatch: [ + { + op: 'replace', + path: '/state', + value: 'inactive', + }, + ], + }); + + console.log('Blocked identity', data); + + revalidatePath('/user'); +} + +export async function unblockIdentity({ id }: IdentityIdProps) { + + const identityApi = await getIdentityApi(); + const { data } = await identityApi.patchIdentity({ + id, + jsonPatch: [ + { + op: 'replace', + path: '/state', + value: 'active', + }, + ], + }); + + console.log('Unblocked identity', data); + + revalidatePath('/user'); +} + +export async function deleteIdentity({ id }: IdentityIdProps) { + + const identityApi = await getIdentityApi(); + const { data } = await identityApi.deleteIdentity({ id }); + + console.log('Deleted identity', data); + + revalidatePath('/user'); +} diff --git a/dashboard/src/app/user/data-table.tsx b/dashboard/src/app/user/data-table.tsx index 49cdefb..8501043 100644 --- a/dashboard/src/app/user/data-table.tsx +++ b/dashboard/src/app/user/data-table.tsx @@ -3,11 +3,33 @@ import { ColumnDef } from '@tanstack/react-table'; import { Identity } from '@ory/client'; import { DataTable } from '@/components/ui/data-table'; -import { CircleCheck, CircleX } from 'lucide-react'; +import { CircleCheck, CircleX, Copy, MoreHorizontal, Trash, UserCheck, UserMinus, UserX } from 'lucide-react'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; -import { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { FetchIdentityPageProps } from '@/app/user/page'; import { Spinner } from '@/components/ui/spinner'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { blockIdentity, deleteIdentity, deleteIdentitySessions, unblockIdentity } from '@/app/user/action'; interface IdentityDataTableProps { data: Identity[]; @@ -85,31 +107,98 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent } }, }, + { + id: 'actions', + cell: ({ row }) => { + const identity = row.original; + return ( + + + + + + Actions + { + navigator.clipboard.writeText(identity.id); + toast.success('Copied to clipboard'); + }} + > + + Copy identity ID + + + + View identity + + + View sessions + + + { + setCurrentIdentity(identity); + setIdentitySessionVisible(true); + }} + className="flex items-center space-x-2 text-red-500"> + + Delete sessions + + { + identity.state === 'active' && + { + setCurrentIdentity(identity); + setBlockIdentityVisible(true); + }} + className="flex items-center space-x-2 text-red-500"> + + Block identity + + } + { + identity.state === 'inactive' && + { + setCurrentIdentity(identity); + setUnblockIdentityVisible(true); + }} + className="flex items-center space-x-2 text-red-500"> + + Unblock identity + + } + { + setCurrentIdentity(identity); + setDeleteIdentityVisible(true); + }} + className="flex items-center space-x-2 text-red-500"> + + Delete identity + + + + ); + }, + }, ]; const [items, setItems] = useState(data); const [nextToken, setNextToken] = useState(pageToken); + // react on changes from ssr (query params) useEffect(() => { setItems(data); setNextToken(pageToken); }, [data, pageSize, pageToken, query]); - const fetchMore = async () => { - if (!nextToken) return; - - const response = await fetchIdentityPage({ - pageSize: pageSize, - pageToken: nextToken, - query: query, - }); - - setItems([...items, ...response.data]); - setNextToken(response.tokens.get('next') ?? undefined); - }; - + // infinite scroll handling const infiniteScrollSensor = useRef(null); - useEffect(() => { const observer = new IntersectionObserver( (entries) => { @@ -131,9 +220,124 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent }; }, [items]); + const fetchMore = async () => { + if (!nextToken) return; + + const response = await fetchIdentityPage({ + pageSize: pageSize, + pageToken: nextToken, + query: query, + }); + + setItems([...items, ...response.data]); + setNextToken(response.tokens.get('next') ?? undefined); + }; + + // quick actions + const [currentIdentity, setCurrentIdentity] = useState(undefined); + const [identitySessionVisible, setIdentitySessionVisible] = useState(false); + const [blockIdentityVisible, setBlockIdentityVisible] = useState(false); + const [unblockIdentityVisible, setUnblockIdentityVisible] = useState(false); + const [deleteIdentityVisible, setDeleteIdentityVisible] = useState(false); + return ( <> + { + currentIdentity && ( + <> + {/* delete sessions dialog */} + setIdentitySessionVisible(open)}> + + + Delete sessions + + Are you sure you want to delete this identity's sessions? + {JSON.stringify(currentIdentity.traits, null, 4)} + + + + deleteIdentitySessions({ id: currentIdentity.id })}> + Invalidate sessions + + + Cancel + + + + + + {/* block identity dialog */} + setBlockIdentityVisible(open)}> + + + Block identity + + Are you sure you want to block this identity? + {JSON.stringify(currentIdentity.traits, null, 4)} + + + + blockIdentity({ id: currentIdentity.id })}> + Block identity + + + Cancel + + + + + + {/* unblock identity dialog */} + setUnblockIdentityVisible(open)}> + + + Unblock identity + + Are you sure you want to unblock this identity? + {JSON.stringify(currentIdentity.traits, null, 4)} + + + + unblockIdentity({ id: currentIdentity.id })}> + Unblock identity + + + Cancel + + + + + + {/* delete identity dialog */} + setDeleteIdentityVisible(open)}> + + + Delete identity + + Are you sure you want to delete this identity? + This action can not be undone! + {JSON.stringify(currentIdentity.traits, null, 4)} + + + + deleteIdentity({ id: currentIdentity.id })}> + Delete identity + + + Cancel + + + + + + ) + } { nextToken && (
diff --git a/dashboard/src/app/user/page.tsx b/dashboard/src/app/user/page.tsx index b6bfa01..6fe3108 100644 --- a/dashboard/src/app/user/page.tsx +++ b/dashboard/src/app/user/page.tsx @@ -70,7 +70,7 @@ export default async function UserPage(

- + }, + { className, ref }: { className?: string, ref?: RefObject }, ) =>