NORY-22: add quick actions to identity page
This commit is contained in:
parent
7299a8e206
commit
aef8e84048
6 changed files with 360 additions and 18 deletions
40
dashboard/src/app/user/[id]/page.tsx
Normal file
40
dashboard/src/app/user/[id]/page.tsx
Normal file
|
@ -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 <ErrorDisplay
|
||||
title="Identity not found"
|
||||
message={`The requested identity with id ${identityId} does not exist`}/>;
|
||||
}
|
||||
|
||||
if (!identity.verifiable_addresses || !identity.verifiable_addresses[0]) {
|
||||
return <ErrorDisplay
|
||||
title="No verifiable adress"
|
||||
message="The identity you are trying to see exists but has no identifiable address"/>;
|
||||
}
|
||||
|
||||
const address = identity.verifiable_addresses[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-3xl font-bold leading-tight tracking-tight">{address.value}</p>
|
||||
<p className="text-lg font-light">{identity.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
32
dashboard/src/app/user/[id]/sessions/page.tsx
Normal file
32
dashboard/src/app/user/[id]/sessions/page.tsx
Normal file
|
@ -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 <ErrorDisplay
|
||||
title="Identity not found"
|
||||
message={`The requested identity with id ${identityId} does not exist`}/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-3xl font-bold leading-tight tracking-tight">Sessions</p>
|
||||
<p className="text-lg font-light">These are all active sessions of the identity</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
66
dashboard/src/app/user/action.ts
Normal file
66
dashboard/src/app/user/action.ts
Normal file
|
@ -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');
|
||||
}
|
|
@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4"/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center space-x-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(identity.id);
|
||||
toast.success('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4"/>
|
||||
<span>Copy identity ID</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator/>
|
||||
<Link href={`/user/${identity.id}`}>
|
||||
<DropdownMenuItem>View identity</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={`/user/${identity.id}/sessions`}>
|
||||
<DropdownMenuItem>View sessions</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentIdentity(identity);
|
||||
setIdentitySessionVisible(true);
|
||||
}}
|
||||
className="flex items-center space-x-2 text-red-500">
|
||||
<UserMinus className="h-4 w-4"/>
|
||||
<span>Delete sessions</span>
|
||||
</DropdownMenuItem>
|
||||
{
|
||||
identity.state === 'active' &&
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentIdentity(identity);
|
||||
setBlockIdentityVisible(true);
|
||||
}}
|
||||
className="flex items-center space-x-2 text-red-500">
|
||||
<UserX className="h-4 w-4"/>
|
||||
<span>Block identity</span>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
{
|
||||
identity.state === 'inactive' &&
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentIdentity(identity);
|
||||
setUnblockIdentityVisible(true);
|
||||
}}
|
||||
className="flex items-center space-x-2 text-red-500">
|
||||
<UserCheck className="h-4 w-4"/>
|
||||
<span>Unblock identity</span>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentIdentity(identity);
|
||||
setDeleteIdentityVisible(true);
|
||||
}}
|
||||
className="flex items-center space-x-2 text-red-500">
|
||||
<Trash className="h-4 w-4"/>
|
||||
<span>Delete identity</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [items, setItems] = useState<Identity[]>(data);
|
||||
const [nextToken, setNextToken] = useState<string | undefined>(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<Identity | undefined>(undefined);
|
||||
const [identitySessionVisible, setIdentitySessionVisible] = useState(false);
|
||||
const [blockIdentityVisible, setBlockIdentityVisible] = useState(false);
|
||||
const [unblockIdentityVisible, setUnblockIdentityVisible] = useState(false);
|
||||
const [deleteIdentityVisible, setDeleteIdentityVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable columns={columns} data={items}/>
|
||||
{
|
||||
currentIdentity && (
|
||||
<>
|
||||
{/* delete sessions dialog */}
|
||||
<AlertDialog
|
||||
open={identitySessionVisible}
|
||||
onOpenChange={(open) => setIdentitySessionVisible(open)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete sessions</AlertDialogTitle>
|
||||
<AlertDialogDescription className="grid grid-cols-1 gap-3">
|
||||
<span>Are you sure you want to delete this identity's sessions?</span>
|
||||
<span>{JSON.stringify(currentIdentity.traits, null, 4)}</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => deleteIdentitySessions({ id: currentIdentity.id })}>
|
||||
Invalidate sessions
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* block identity dialog */}
|
||||
<AlertDialog open={blockIdentityVisible} onOpenChange={(open) => setBlockIdentityVisible(open)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Block identity</AlertDialogTitle>
|
||||
<AlertDialogDescription className="grid grid-cols-1 gap-3">
|
||||
<span>Are you sure you want to block this identity?</span>
|
||||
<span>{JSON.stringify(currentIdentity.traits, null, 4)}</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction
|
||||
onClick={() => blockIdentity({ id: currentIdentity.id })}>
|
||||
Block identity
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* unblock identity dialog */}
|
||||
<AlertDialog open={unblockIdentityVisible} onOpenChange={(open) => setUnblockIdentityVisible(open)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unblock identity</AlertDialogTitle>
|
||||
<AlertDialogDescription className="grid grid-cols-1 gap-3">
|
||||
<span>Are you sure you want to unblock this identity?</span>
|
||||
<span>{JSON.stringify(currentIdentity.traits, null, 4)}</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction
|
||||
onClick={() => unblockIdentity({ id: currentIdentity.id })}>
|
||||
Unblock identity
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* delete identity dialog */}
|
||||
<AlertDialog open={deleteIdentityVisible} onOpenChange={(open) => setDeleteIdentityVisible(open)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete identity</AlertDialogTitle>
|
||||
<AlertDialogDescription className="grid grid-cols-1 gap-3">
|
||||
<span>Are you sure you want to delete this identity?</span>
|
||||
<strong className="text-yellow-500">This action can not be undone!</strong>
|
||||
<span>{JSON.stringify(currentIdentity.traits, null, 4)}</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteIdentity({ id: currentIdentity.id })}>
|
||||
Delete identity
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
nextToken && (
|
||||
<div className="flex w-full justify-center">
|
||||
|
|
|
@ -70,7 +70,7 @@ export default async function UserPage(
|
|||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<SearchInput queryParamKey="query" placeholder="Search"/>
|
||||
<SearchInput queryParamKey="query" placeholder="Search for identifiers (Email, Username...)"/>
|
||||
<IdentityDataTable
|
||||
data={initialFetch.data}
|
||||
pageSize={pageSize}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { cn } from '@/lib/utils';
|
|||
import { RefObject } from 'react';
|
||||
|
||||
export const Spinner = (
|
||||
{ className, ref }: { className?: string, ref: RefObject<any> },
|
||||
{ className, ref }: { className?: string, ref?: RefObject<any> },
|
||||
) => <svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
|
|
Loading…
Add table
Reference in a new issue