NORY-15: add identity page (#28)

This commit is contained in:
Markus Thielker 2024-12-08 08:42:08 +01:00 committed by GitHub
commit 6e0bbf3954
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 453 additions and 11 deletions

Binary file not shown.

View file

@ -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",

View file

@ -73,6 +73,6 @@
}
body {
@apply bg-background text-foreground;
@apply bg-sidebar text-foreground;
}
}

View file

@ -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
>
<SidebarProvider>
<SidebarProvider className="max-h-screen min-h-screen">
<AppSidebar className="mx-1"/>
<SidebarInset className="p-2">
<header className="flex h-16 shrink-0 items-center gap-2 px-4">
<SidebarInset className="overflow-hidden p-6 space-y-6">
<header className="flex h-4 items-center gap-2">
<SidebarTrigger className="-ml-1 p-1"/>
<Separator orientation="vertical" className="mr-2 h-4"/>
{
@ -75,12 +75,12 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
</BreadcrumbList>
</Breadcrumb>
</header>
<div className="flex flex-col p-4 pt-0">
<div className="flex-1 overflow-scroll">
{children}
<Toaster/>
</div>
</SidebarInset>
</SidebarProvider>
<Toaster/>
</ThemeProvider>
</body>
</html>

View file

@ -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<string, string> }>;
}
export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdentityPage }: IdentityDataTableProps) {
const columns: ColumnDef<Identity>[] = [
{
id: 'id',
accessorKey: 'id',
header: 'ID',
},
{
id: 'active',
header: 'Active',
cell: ({ row }) => {
const identity = row.original;
if (identity.state === 'active') {
return <CircleCheck/>;
} else {
return <CircleX/>;
}
},
},
{
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 <p>Something went wrong</p>;
} else {
return (
<div className="flex flex-row space-x-2 items-center">
<span>{email.value}</span>
{
email.verified ?
<HoverCard>
<HoverCardTrigger>
<CircleCheck/>
</HoverCardTrigger>
<HoverCardContent>
<span>This email was confirmed at </span>
{new Date(email.verified_at!!).toLocaleString()}
</HoverCardContent>
</HoverCard>
:
<HoverCard>
<HoverCardTrigger>
<CircleX/>
</HoverCardTrigger>
<HoverCardContent>
This email is not confirmed yet
</HoverCardContent>
</HoverCard>
}
</div>
);
}
},
},
];
const [items, setItems] = useState<Identity[]>(data);
const [nextToken, setNextToken] = useState<string | undefined>(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 (
<>
<DataTable columns={columns} data={items}/>
{
nextToken && (
<div className="flex w-full justify-center">
<Spinner ref={infiniteScrollSensor} className="h-10"/>
</div>
)
}
</>
);
}

View file

@ -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 (
<div className="space-y-4">
<div>
<p className="text-3xl font-bold leading-tight tracking-tight">Users</p>
<p className="text-lg font-light">
See and manage all identities registered with your Ory Kratos instance
</p>
</div>
<div className="space-y-2">
<SearchInput queryParamKey="query" placeholder="Search"/>
<IdentityDataTable
data={initialFetch.data}
pageSize={pageSize}
pageToken={initialFetch.tokens.get('next')}
query={query}
fetchIdentityPage={fetchIdentityPage}/>
</div>
</div>
);
}

View file

@ -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<typeof Sidebar>) {
@ -54,10 +55,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<Link href={item.url}>
<item.icon/>
<span>{item.title}</span>
</a>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}

View file

@ -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<HTMLInputElement>) => {
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 (
<Input
type={type ?? 'text'}
placeholder={placeholder}
className={className}
onChange={onSearchChange}/>
);
}

View file

@ -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 (
<div className="rounded-md border">
<Table>
<TableHeader>
{
columns.map((_, index) => {
return (
<TableHead key={index}>
<Skeleton className="w-20 h-6"/>
</TableHead>
);
})
}
</TableHeader>
<TableBody>
{
rows.map((_, index) =>
<TableRow key={index} className="h-4">
{
columns.map((_, index) =>
<TableCell key={index} className="h-4">
<Skeleton className="w-full h-4"/>
</TableCell>,
)
}
</TableRow>,
)
}
</TableBody>
</Table>
</div>
);
}

View file

@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>(
{
columns,
data,
}: DataTableProps<TData, TValue>,
) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View file

@ -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<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View file

@ -0,0 +1,20 @@
import { cn } from '@/lib/utils';
import { RefObject } from 'react';
export const Spinner = (
{ className, ref }: { className?: string, ref: RefObject<any> },
) => <svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn('animate-spin', className)}
ref={ref}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>;