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:
parent
9f497ce99d
commit
66775a001e
10 changed files with 54 additions and 35 deletions
3
dashboard/src/app/(inside)/application/page.tsx
Normal file
3
dashboard/src/app/(inside)/application/page.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default async function ApplicationPage() {
|
||||
return <></>;
|
||||
}
|
37
dashboard/src/app/(inside)/layout.tsx
Normal file
37
dashboard/src/app/(inside)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
113
dashboard/src/app/(inside)/page.tsx
Normal file
113
dashboard/src/app/(inside)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
259
dashboard/src/app/(inside)/user/[id]/page.tsx
Normal file
259
dashboard/src/app/(inside)/user/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
66
dashboard/src/app/(inside)/user/action.ts
Normal file
66
dashboard/src/app/(inside)/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');
|
||||
}
|
350
dashboard/src/app/(inside)/user/data-table.tsx
Normal file
350
dashboard/src/app/(inside)/user/data-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
83
dashboard/src/app/(inside)/user/page.tsx
Normal file
83
dashboard/src/app/(inside)/user/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue