diff --git a/dashboard/bun.lockb b/dashboard/bun.lockb index 600a222..af3b2c6 100755 Binary files a/dashboard/bun.lockb and b/dashboard/bun.lockb differ diff --git a/dashboard/package.json b/dashboard/package.json index 2583465..569f906 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-separator": "^1.1.0", @@ -25,6 +26,7 @@ "@serwist/next": "^9.0.0-preview.21", "@serwist/precaching": "^9.0.0-preview.21", "@serwist/sw": "^9.0.0-preview.21", + "@tanstack/react-query": "^5.62.3", "@tanstack/react-table": "^8.20.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css index 0766540..262da57 100644 --- a/dashboard/src/app/globals.css +++ b/dashboard/src/app/globals.css @@ -73,6 +73,6 @@ } body { - @apply bg-background text-foreground; + @apply bg-sidebar text-foreground; } } \ No newline at end of file diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx index 51548bd..94cc7a8 100644 --- a/dashboard/src/app/layout.tsx +++ b/dashboard/src/app/layout.tsx @@ -35,7 +35,7 @@ export const metadata = { }; export const viewport: Viewport = { - themeColor: '#0B0908', + themeColor: '#18181b', width: 'device-width', }; @@ -56,10 +56,10 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac enableSystem disableTransitionOnChange > - + - -
+ +
{ @@ -75,12 +75,12 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
-
+
{children} -
+ diff --git a/dashboard/src/app/user/data-table.tsx b/dashboard/src/app/user/data-table.tsx new file mode 100644 index 0000000..49cdefb --- /dev/null +++ b/dashboard/src/app/user/data-table.tsx @@ -0,0 +1,146 @@ +'use client'; + +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 { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; +import { useEffect, useRef, useState } from 'react'; +import { FetchIdentityPageProps } from '@/app/user/page'; +import { Spinner } from '@/components/ui/spinner'; + +interface IdentityDataTableProps { + data: Identity[]; + pageSize: number; + pageToken: string | undefined; + query: string; + fetchIdentityPage: (props: FetchIdentityPageProps) => Promise<{ data: Identity[], tokens: Map }>; +} + +export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdentityPage }: IdentityDataTableProps) { + + const columns: ColumnDef[] = [ + { + id: 'id', + accessorKey: 'id', + header: 'ID', + }, + { + id: 'active', + header: 'Active', + cell: ({ row }) => { + + const identity = row.original; + + if (identity.state === 'active') { + return ; + } else { + return ; + } + }, + }, + { + id: 'name', + accessorKey: 'traits.name', + header: 'Name', + }, + { + id: 'email', + header: 'Email', + cell: ({ row }) => { + + const identity = row.original; + const email = identity.verifiable_addresses ? + identity.verifiable_addresses[0] : undefined; + + if (!email) { + return

Something went wrong

; + } else { + return ( +
+ {email.value} + { + email.verified ? + + + + + + This email was confirmed at + {new Date(email.verified_at!!).toLocaleString()} + + + : + + + + + + This email is not confirmed yet + + + } +
+ ); + } + }, + }, + ]; + + const [items, setItems] = useState(data); + const [nextToken, setNextToken] = useState(pageToken); + + 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); + }; + + const infiniteScrollSensor = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + fetchMore(); + } + }, + { threshold: 0.5 }, // Adjust threshold as needed + ); + + if (infiniteScrollSensor.current) { + observer.observe(infiniteScrollSensor.current); + } + + return () => { + if (infiniteScrollSensor.current) { + observer.unobserve(infiniteScrollSensor.current); + } + }; + }, [items]); + + return ( + <> + + { + nextToken && ( +
+ +
+ ) + } + + ); +} diff --git a/dashboard/src/app/user/page.tsx b/dashboard/src/app/user/page.tsx index 2beb9f6..b6bfa01 100644 --- a/dashboard/src/app/user/page.tsx +++ b/dashboard/src/app/user/page.tsx @@ -1,3 +1,83 @@ -export default async function UserPage() { - return <>; +import React from 'react'; +import { IdentityDataTable } from '@/app/user/data-table'; +import { getIdentityApi } from '@/ory/sdk/server'; +import { SearchInput } from '@/components/search-input'; + +export interface FetchIdentityPageProps { + pageSize: number; + pageToken: string; + query: string; +} + +async function fetchIdentityPage({ pageSize, pageToken, query }: FetchIdentityPageProps) { + 'use server'; + + const identityApi = await getIdentityApi(); + const response = await identityApi.listIdentities({ + pageSize: pageSize, + pageToken: pageToken, + previewCredentialsIdentifierSimilar: query, + }); + + return { + data: response.data, + tokens: parseTokens(response.headers.link), + }; +} + +function parseTokens(link: string) { + + const parsed = link.split(',').map((it) => { + const startRel = it.lastIndexOf('rel="'); + const endRel = it.lastIndexOf('"'); + const rel = it.slice(startRel, endRel); + + const startToken = it.lastIndexOf('page_token='); + const endToken = it.lastIndexOf('&'); + const token = it.slice(startToken, endToken); + + return [rel, token]; + }); + + return new Map(parsed.map(obj => [ + obj[0].replace('rel="', ''), + obj[1].replace('page_token=', ''), + ])); +} + +export default async function UserPage( + { + searchParams, + }: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }> + }, +) { + + const params = await searchParams; + const query = params.query ? params.query as string : ''; + + let pageSize = 250; + let pageToken: string = '00000000-0000-0000-0000-000000000000'; + + const initialFetch = await fetchIdentityPage({ pageSize, pageToken, query: query }); + + return ( +
+
+

Users

+

+ See and manage all identities registered with your Ory Kratos instance +

+
+
+ + +
+
+ ); } diff --git a/dashboard/src/components/app-sidebar.tsx b/dashboard/src/components/app-sidebar.tsx index 5598b4a..7039c7f 100644 --- a/dashboard/src/components/app-sidebar.tsx +++ b/dashboard/src/components/app-sidebar.tsx @@ -21,6 +21,7 @@ import { } from '@/components/ui/dropdown-menu'; import { useTheme } from 'next-themes'; import { LogoutLink } from '@/ory'; +import Link from 'next/link'; export function AppSidebar({ ...props }: React.ComponentProps) { @@ -54,10 +55,10 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {items.map((item) => ( - + {item.title} - + ))} diff --git a/dashboard/src/components/search-input.tsx b/dashboard/src/components/search-input.tsx new file mode 100644 index 0000000..fa6be17 --- /dev/null +++ b/dashboard/src/components/search-input.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { Input } from '@/components/ui/input'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { ChangeEvent, HTMLInputTypeAttribute } from 'react'; + +interface SearchInputProps { + placeholder: string; + queryParamKey: string; + type?: HTMLInputTypeAttribute; + className?: string; +} + +export function SearchInput({ placeholder, queryParamKey, type, className }: SearchInputProps) { + + const router = useRouter(); + const params = useSearchParams(); + + const onSearchChange = (event: ChangeEvent) => { + + const value = event.target.value; + const newParams = new URLSearchParams(params.toString()); + + if (value.length < 1) { + newParams.delete(queryParamKey); + } else { + newParams.set(queryParamKey, value); + } + + router.replace('?' + newParams.toString()); + }; + + return ( + + ); +} diff --git a/dashboard/src/components/ui/data-table-fallback.tsx b/dashboard/src/components/ui/data-table-fallback.tsx new file mode 100644 index 0000000..0825b14 --- /dev/null +++ b/dashboard/src/components/ui/data-table-fallback.tsx @@ -0,0 +1,53 @@ +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; + +interface DataTableFallbackProps { + columnCount: number, + rowCount: number, +} + +export async function DataTableFallback({ columnCount, rowCount }: DataTableFallbackProps) { + + const columns: string[] = []; + for (let i = 0; i < columnCount; i++) { + columns.push(''); + } + + const rows: string[] = []; + for (let i = 0; i < rowCount; i++) { + rows.push(''); + } + + return ( +
+ + + { + columns.map((_, index) => { + return ( + + + + ); + }) + } + + + { + rows.map((_, index) => + + { + columns.map((_, index) => + + + , + ) + } + , + ) + } + +
+
+ ); +} diff --git a/dashboard/src/components/ui/data-table.tsx b/dashboard/src/components/ui/data-table.tsx new file mode 100644 index 0000000..125509e --- /dev/null +++ b/dashboard/src/components/ui/data-table.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import React from 'react'; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable( + { + columns, + data, + }: DataTableProps, +) { + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +} diff --git a/dashboard/src/components/ui/hover-card.tsx b/dashboard/src/components/ui/hover-card.tsx new file mode 100644 index 0000000..ec52841 --- /dev/null +++ b/dashboard/src/components/ui/hover-card.tsx @@ -0,0 +1,29 @@ +'use client'; + +import * as React from 'react'; +import * as HoverCardPrimitive from '@radix-ui/react-hover-card'; + +import { cn } from '@/lib/utils'; + +const HoverCard = HoverCardPrimitive.Root; + +const HoverCardTrigger = HoverCardPrimitive.Trigger; + +const HoverCardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + +)); +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; + +export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/dashboard/src/components/ui/spinner.tsx b/dashboard/src/components/ui/spinner.tsx new file mode 100644 index 0000000..e1bfb8b --- /dev/null +++ b/dashboard/src/components/ui/spinner.tsx @@ -0,0 +1,20 @@ +import { cn } from '@/lib/utils'; +import { RefObject } from 'react'; + +export const Spinner = ( + { className, ref }: { className?: string, ref: RefObject }, +) => + +;