diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx index 94cc7a8..8fda46d 100644 --- a/dashboard/src/app/layout.tsx +++ b/dashboard/src/app/layout.tsx @@ -79,8 +79,8 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac {children} + - diff --git a/dashboard/src/app/user/[id]/page.tsx b/dashboard/src/app/user/[id]/page.tsx new file mode 100644 index 0000000..44806a3 --- /dev/null +++ b/dashboard/src/app/user/[id]/page.tsx @@ -0,0 +1,259 @@ +import React from 'react'; +import { getIdentityApi } from '@/ory/sdk/server'; +import { ErrorDisplay } from '@/components/error'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { IdentityTraitForm } from '@/components/forms/IdentityTraitForm'; +import { KratosSchema } from '@/lib/forms/identity-form'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { UAParser } from 'ua-parser-js'; +import { RecoveryIdentityAddress, VerifiableIdentityAddress } from '@ory/client'; +import { Badge } from '@/components/ui/badge'; +import { Check, X } from 'lucide-react'; + +interface MergedAddress { + recovery_id?: string; + verifiable_id?: string; + verified?: boolean; + verified_at?: string; + value: string; + via: string; +} + +function mergeAddresses( + recovery: RecoveryIdentityAddress[], + verifiable: VerifiableIdentityAddress[], +): MergedAddress[] { + + const merged = [...recovery, ...verifiable]; + return merged.reduce((acc: MergedAddress[], curr: any) => { + + const existingValue = + acc.find(item => item.value && curr.value && item.value === curr.value); + + if (!existingValue) { + + let newEntry: MergedAddress; + if (curr.status) { + + // status property exists only in verifiable addresses + // expecting verifiable address + newEntry = { + verifiable_id: curr.id, + verified: curr.verified, + verified_at: curr.verified_at, + value: curr.value, + via: curr.via, + } as MergedAddress; + + } else { + + // expecting recovery address + newEntry = { + recovery_id: curr.id, + value: curr.value, + via: curr.via, + } as MergedAddress; + } + + acc.push(newEntry); + + } else { + + const additionalValues = { + recovery_id: existingValue.recovery_id, + verifiable_id: curr.id, + verified: curr.verified, + verified_at: curr.verified_at, + }; + + Object.assign(existingValue, additionalValues); + } + return acc; + }, []); +} + +export default async function UserDetailsPage({ params }: { params: Promise<{ id: string }> }) { + + const identityId = (await params).id; + + const identityApi = await getIdentityApi(); + const identity = await identityApi.getIdentity({ id: identityId }) + .then((response) => { + console.log('identity', response.data); + return response.data; + }) + .catch(() => { + console.log('Identity not found'); + }); + + const sessions = await identityApi.listIdentitySessions({ id: identityId }) + .then((response) => { + console.log('sessions', response.data); + return response.data; + }) + .catch(() => { + console.log('No sessions found'); + }); + + if (!identity) { + return ; + } + + if (!identity.verifiable_addresses || !identity.verifiable_addresses[0]) { + return ; + } + + const identitySchema = await identityApi + .getIdentitySchema({ id: identity.schema_id }) + .then((response) => response.data as KratosSchema); + + const addresses = mergeAddresses( + identity.recovery_addresses ?? [], + identity.verifiable_addresses ?? [], + ); + + return ( +
+
+

{addresses[0].value}

+

{identity.id}

+
+
+ + + Traits + All identity properties specified in the identity schema + + + + + + + + Addresses + All linked addresses for verification and recovery + + + + + + + Type + Value + + + + { + addresses.map((address) => { + return ( + + {address.value} + {address.via} + + {address.verifiable_id && + + Verifiable + { + address.verified ? + + : + + } + + } + {address.recovery_id && + Recovery + } + + + ); + }) + } + +
+
+
+ + + Credentials + All authentication mechanisms registered with this identity + + + + + + Type + Value + + + + { + Object.entries(identity.credentials!).map(([key, value]) => { + return ( + + {key} + {value.identifiers![0]} + + ); + }) + } + +
+
+
+ + + Sessions + See and manage all sessions of this identity + + + + + + OS + Browser + Active since + + + + { + sessions ? + sessions.map((session) => { + + const device = session.devices![0]; + const parser = new UAParser(device.user_agent); + const result = parser.getResult(); + + return ( + + + {result.os.name} + {result.os.version} + + + {result.browser.name} + {result.browser.version} + + + {new Date(session.authenticated_at!).toLocaleString()} + + + ); + }) + : + + } + +
+
+
+
+
+ ); +} 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..5819331 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, UserPen, 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 + + + + { + 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(

- + +

{title}

+

{message}

+
+ ); +} \ No newline at end of file diff --git a/dashboard/src/components/forms/IdentityTraitForm.tsx b/dashboard/src/components/forms/IdentityTraitForm.tsx new file mode 100644 index 0000000..8f863d8 --- /dev/null +++ b/dashboard/src/components/forms/IdentityTraitForm.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { generateZodSchema, KratosSchema, KratosSchemaProperties } from '@/lib/forms/identity-form'; +import { useForm, UseFormReturn } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { Identity } from '@ory/client'; +import { Checkbox } from '@/components/ui/checkbox'; + +interface IdentityTraitFormProps { + schema: KratosSchema; + identity: Identity; +} + +function renderUiNodes(form: UseFormReturn, properties: KratosSchemaProperties, prefix?: string): any { + + let keyPrefix = prefix ? prefix + '.' : ''; + + return Object.entries(properties).map(([key, value]) => { + if (value.type === 'object') { + return renderUiNodes(form, value.properties!, key); + } else if (value.type === 'boolean') { + return ( + ( + + + {value.title} + + )} + /> + ); + } else { + return ( + ( + + {value.title} + + + + + )} + /> + ); + } + }, + ); +} + +export function IdentityTraitForm({ schema, identity }: IdentityTraitFormProps) { + + const zodIdentitySchema = generateZodSchema(schema); + const form = useForm>({ + defaultValues: identity.traits, + resolver: zodResolver(zodIdentitySchema), + }); + + function onSubmit(values: z.infer) { + toast.message(JSON.stringify(values, null, 4)); + } + + return ( +
+ + { + renderUiNodes(form, schema.properties.traits.properties) + } +
+ + ); +} diff --git a/dashboard/src/components/ui/spinner.tsx b/dashboard/src/components/ui/spinner.tsx index e1bfb8b..af253fc 100644 --- a/dashboard/src/components/ui/spinner.tsx +++ b/dashboard/src/components/ui/spinner.tsx @@ -2,7 +2,7 @@ import { cn } from '@/lib/utils'; import { RefObject } from 'react'; export const Spinner = ( - { className, ref }: { className?: string, ref: RefObject }, + { className, ref }: { className?: string, ref?: RefObject }, ) =>