1
0
Fork 0
mirror of https://codeberg.org/MarkusThielker/next-ory.git synced 2025-07-05 06:09:18 +00:00

NORY-36: add error page for unauthorised users

This commit is contained in:
Markus Thielker 2024-12-15 04:28:26 +01:00
parent 9f497ce99d
commit 66775a001e
No known key found for this signature in database
10 changed files with 54 additions and 35 deletions

View file

@ -0,0 +1,3 @@
export default async function ApplicationPage() {
return <></>;
}

View file

@ -0,0 +1,37 @@
import '../globals.css';
import { Toaster } from '@/components/ui/sonner';
import React from 'react';
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { Separator } from '@/components/ui/separator';
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb';
export default function InsideLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<SidebarProvider className="max-h-screen min-h-screen">
<AppSidebar className="mx-1"/>
<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"/>
{
// TODO: implement dynamic Breadcrumbs
}
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="/">
Ory Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<div className="flex-1 overflow-scroll">
{children}
</div>
</SidebarInset>
<Toaster/>
</SidebarProvider>
);
}

View file

@ -0,0 +1,113 @@
import { getHydraMetadataApi, getKetoMetadataApi, getKratosMetadataApi } from '@/ory/sdk/server';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
export default async function RootPage() {
const kratosMetadataApi = await getKratosMetadataApi();
const kratosVersion = await kratosMetadataApi
.getVersion()
.then(res => res.data.version)
.catch(() => '');
const kratosStatusData = await fetch(process.env.ORY_KRATOS_ADMIN_URL + '/health/alive');
const kratosStatus = await kratosStatusData.json() as { status: string };
const kratosDBStatusData = await fetch(process.env.ORY_KRATOS_ADMIN_URL + '/health/ready');
const kratosDBStatus = await kratosDBStatusData.json() as { status: string };
const hydraMetadataApi = await getHydraMetadataApi();
const hydraVersion = await hydraMetadataApi
.getVersion()
.then(res => res.data.version)
.catch(() => '');
const hydraStatusData = await fetch(process.env.ORY_KRATOS_ADMIN_URL + '/health/alive');
const hydraStatus = await hydraStatusData.json() as { status: string };
const hydraDBStatusData = await fetch(process.env.ORY_KRATOS_ADMIN_URL + '/health/ready');
const hydraDBStatus = await hydraDBStatusData.json() as { status: string };
const ketoMetadataApi = await getKetoMetadataApi();
const ketoVersion = await ketoMetadataApi
.getVersion()
.then(res => res.data.version)
.catch(() => '');
const ketoStatusData = await fetch(process.env.ORY_KETO_ADMIN_URL + '/health/alive');
const ketoStatus = await ketoStatusData.json() as { status: string };
const ketoDBStatusData = await fetch(process.env.ORY_KETO_ADMIN_URL + '/health/ready');
const ketoDBStatus = await ketoDBStatusData.json() as { status: string };
return (
<div className="flex flex-col space-y-4">
<div>
<p className="text-3xl font-bold leading-tight tracking-tight">Software Stack</p>
<p className="text-lg font-light">See the list of all applications in your stack</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="flex-1">
<CardHeader>
<CardTitle>
Ory Kratos
</CardTitle>
<CardDescription>
Version {kratosVersion}
</CardDescription>
</CardHeader>
<CardContent className="space-x-1">
<Badge variant={kratosStatus.status === 'ok' ? 'success' : 'destructive'}>
Kratos {kratosStatus.status.toUpperCase()}
</Badge>
<Badge variant={kratosStatus.status === 'ok' ? 'success' : 'destructive'}>
Database {kratosDBStatus.status.toUpperCase()}
</Badge>
</CardContent>
</Card>
<Card className="flex-1">
<CardHeader>
<CardTitle>
Ory Hydra
</CardTitle>
<CardDescription>
Version {hydraVersion}
</CardDescription>
</CardHeader>
<CardContent className="space-x-1">
<Badge variant={kratosStatus.status === 'ok' ? 'success' : 'destructive'}>
Hydra {hydraStatus.status.toUpperCase()}
</Badge>
<Badge variant={kratosStatus.status === 'ok' ? 'success' : 'destructive'}>
Database {hydraDBStatus.status.toUpperCase()}
</Badge>
</CardContent>
</Card>
<Card className="flex-1">
<CardHeader>
<CardTitle>
Ory Keto
</CardTitle>
<CardDescription>
Version {ketoVersion}
</CardDescription>
</CardHeader>
<CardContent className="space-x-1">
<Badge variant={kratosStatus.status === 'ok' ? 'success' : 'destructive'}>
Keto {ketoStatus.status.toUpperCase()}
</Badge>
<Badge variant={kratosStatus.status === 'ok' ? 'success' : 'destructive'}>
Database {ketoDBStatus.status.toUpperCase()}
</Badge>
</CardContent>
</Card>
<div className="flex-1"></div>
</div>
</div>
);
}

View file

@ -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 <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 identitySchema = await identityApi
.getIdentitySchema({ id: identity.schema_id })
.then((response) => response.data as KratosSchema);
const addresses = mergeAddresses(
identity.recovery_addresses ?? [],
identity.verifiable_addresses ?? [],
);
return (
<div className="space-y-4">
<div>
<p className="text-3xl font-bold leading-tight tracking-tight">{addresses[0].value}</p>
<p className="text-lg font-light">{identity.id}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Traits</CardTitle>
<CardDescription>All identity properties specified in the identity schema</CardDescription>
</CardHeader>
<CardContent>
<IdentityTraitForm schema={identitySchema} identity={identity}/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Addresses</CardTitle>
<CardDescription>All linked addresses for verification and recovery</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{
addresses.map((address) => {
return (
<TableRow key={address.value}>
<TableCell>{address.value}</TableCell>
<TableCell>{address.via}</TableCell>
<TableCell>
{address.verifiable_id &&
<Badge className="m-1 space-x-1">
<span>Verifiable</span>
{
address.verified ?
<Check className="h-3 w-3"/>
:
<X className="h-3 w-3"/>
}
</Badge>
}
{address.recovery_id &&
<Badge className="m-1">Recovery</Badge>
}
</TableCell>
</TableRow>
);
})
}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Credentials</CardTitle>
<CardDescription>All authentication mechanisms registered with this identity</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{
Object.entries(identity.credentials!).map(([key, value]) => {
return (
<TableRow key={key}>
<TableCell>{key}</TableCell>
<TableCell>{value.identifiers![0]}</TableCell>
</TableRow>
);
})
}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Sessions</CardTitle>
<CardDescription>See and manage all sessions of this identity</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>OS</TableHead>
<TableHead>Browser</TableHead>
<TableHead>Active since</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{
sessions ?
sessions.map((session) => {
const device = session.devices![0];
const parser = new UAParser(device.user_agent);
const result = parser.getResult();
return (
<TableRow key={session.id}>
<TableCell className="space-x-1">
<span>{result.os.name}</span>
<span
className="text-xs text-neutral-500">{result.os.version}</span>
</TableCell>
<TableCell className="space-x-1">
<span>{result.browser.name}</span>
<span
className="text-xs text-neutral-500">{result.browser.version}</span>
</TableCell>
<TableCell>
{new Date(session.authenticated_at!).toLocaleString()}
</TableCell>
</TableRow>
);
})
:
<ErrorDisplay title="No sessions" message=""/>
}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
);
}

View 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');
}

View file

@ -0,0 +1,350 @@
'use client';
import { ColumnDef } from '@tanstack/react-table';
import { Identity } from '@ory/client';
import { DataTable } from '@/components/ui/data-table';
import { CircleCheck, CircleX, Copy, MoreHorizontal, Trash, UserCheck, UserMinus, UserPen, UserX } from 'lucide-react';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import React, { useEffect, useRef, useState } from 'react';
import { FetchIdentityPageProps } from '@/app/(inside)/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/(inside)/user/action';
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>
);
}
},
},
{
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 className="flex items-center space-x-2">
<UserPen className="h-4 w-4"/>
<span>View identity</span>
</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]);
// infinite scroll handling
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]);
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">
<Spinner ref={infiniteScrollSensor} className="h-10"/>
</div>
)
}
</>
);
}

View file

@ -0,0 +1,83 @@
import React from 'react';
import { IdentityDataTable } from '@/app/(inside)/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 for identifiers (Email, Username...)"/>
<IdentityDataTable
data={initialFetch.data}
pageSize={pageSize}
pageToken={initialFetch.tokens.get('next')}
query={query}
fetchIdentityPage={fetchIdentityPage}/>
</div>
</div>
);
}