NORY-22: add details page for identities (#37)
This commit is contained in:
commit
9a44023b70
9 changed files with 727 additions and 19 deletions
|
@ -79,8 +79,8 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
<Toaster/>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
<Toaster/>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
259
dashboard/src/app/user/[id]/page.tsx
Normal file
259
dashboard/src/app/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/user/action.ts
Normal file
66
dashboard/src/app/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');
|
||||||
|
}
|
|
@ -3,11 +3,33 @@
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Identity } from '@ory/client';
|
import { Identity } from '@ory/client';
|
||||||
import { DataTable } from '@/components/ui/data-table';
|
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 { 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 { FetchIdentityPageProps } from '@/app/user/page';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
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 {
|
interface IdentityDataTableProps {
|
||||||
data: Identity[];
|
data: Identity[];
|
||||||
|
@ -85,31 +107,98 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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 [items, setItems] = useState<Identity[]>(data);
|
||||||
const [nextToken, setNextToken] = useState<string | undefined>(pageToken);
|
const [nextToken, setNextToken] = useState<string | undefined>(pageToken);
|
||||||
|
|
||||||
|
// react on changes from ssr (query params)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setItems(data);
|
setItems(data);
|
||||||
setNextToken(pageToken);
|
setNextToken(pageToken);
|
||||||
}, [data, pageSize, pageToken, query]);
|
}, [data, pageSize, pageToken, query]);
|
||||||
|
|
||||||
const fetchMore = async () => {
|
// infinite scroll handling
|
||||||
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);
|
const infiniteScrollSensor = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
|
@ -131,9 +220,124 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent
|
||||||
};
|
};
|
||||||
}, [items]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable columns={columns} data={items}/>
|
<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 && (
|
nextToken && (
|
||||||
<div className="flex w-full justify-center">
|
<div className="flex w-full justify-center">
|
||||||
|
|
|
@ -70,7 +70,7 @@ export default async function UserPage(
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<SearchInput queryParamKey="query" placeholder="Search"/>
|
<SearchInput queryParamKey="query" placeholder="Search for identifiers (Email, Username...)"/>
|
||||||
<IdentityDataTable
|
<IdentityDataTable
|
||||||
data={initialFetch.data}
|
data={initialFetch.data}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
|
13
dashboard/src/components/error.tsx
Normal file
13
dashboard/src/components/error.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
interface ErrorDisplayProps {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ErrorDisplay({ title, message }: ErrorDisplayProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-3xl font-bold leading-tight tracking-tight">{title}</p>
|
||||||
|
<p className="text-lg font-light">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
82
dashboard/src/components/forms/IdentityTraitForm.tsx
Normal file
82
dashboard/src/components/forms/IdentityTraitForm.tsx
Normal file
|
@ -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 (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={keyPrefix + key}
|
||||||
|
key={key}
|
||||||
|
className="space-y-0"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||||
|
<Checkbox {...field} checked={field.value}/>
|
||||||
|
<FormLabel>{value.title}</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={keyPrefix + key}
|
||||||
|
key={key}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{value.title}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={value.title} readOnly {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdentityTraitForm({ schema, identity }: IdentityTraitFormProps) {
|
||||||
|
|
||||||
|
const zodIdentitySchema = generateZodSchema(schema);
|
||||||
|
const form = useForm<z.infer<typeof zodIdentitySchema>>({
|
||||||
|
defaultValues: identity.traits,
|
||||||
|
resolver: zodResolver(zodIdentitySchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof zodIdentitySchema>) {
|
||||||
|
toast.message(JSON.stringify(values, null, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={onSubmit} className="grid grid-cols-1 gap-4">
|
||||||
|
{
|
||||||
|
renderUiNodes(form, schema.properties.traits.properties)
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import { cn } from '@/lib/utils';
|
||||||
import { RefObject } from 'react';
|
import { RefObject } from 'react';
|
||||||
|
|
||||||
export const Spinner = (
|
export const Spinner = (
|
||||||
{ className, ref }: { className?: string, ref: RefObject<any> },
|
{ className, ref }: { className?: string, ref?: RefObject<any> },
|
||||||
) => <svg
|
) => <svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="24"
|
width="24"
|
||||||
|
|
84
dashboard/src/lib/forms/identity-form.ts
Normal file
84
dashboard/src/lib/forms/identity-form.ts
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// interface for a list of properties
|
||||||
|
export interface KratosSchemaProperties {
|
||||||
|
[key: string]: {
|
||||||
|
type: string;
|
||||||
|
format?: string;
|
||||||
|
title: string;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
required?: boolean;
|
||||||
|
description?: string;
|
||||||
|
properties?: KratosSchemaProperties
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// interface for the kratos identity schema
|
||||||
|
export interface KratosSchema {
|
||||||
|
$id: string;
|
||||||
|
$schema: string;
|
||||||
|
title: string;
|
||||||
|
type: 'object';
|
||||||
|
properties: {
|
||||||
|
traits: {
|
||||||
|
type: 'object';
|
||||||
|
properties: KratosSchemaProperties;
|
||||||
|
required: string[];
|
||||||
|
additionalProperties: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateZodSchema(properties: KratosSchemaProperties) {
|
||||||
|
|
||||||
|
const zodSchema = z.object({});
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
let zodType;
|
||||||
|
switch (value.type) {
|
||||||
|
case 'string':
|
||||||
|
zodType = z.string();
|
||||||
|
if (value.format === 'email') {
|
||||||
|
zodType = z.string().email();
|
||||||
|
}
|
||||||
|
if (value.minLength) {
|
||||||
|
zodType = zodType.min(value.minLength);
|
||||||
|
}
|
||||||
|
if (value.maxLength) {
|
||||||
|
zodType = zodType.max(value.maxLength);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'integer':
|
||||||
|
case 'number':
|
||||||
|
zodType = z.number();
|
||||||
|
if (value.minimum) {
|
||||||
|
zodType = zodType.min(value.minimum);
|
||||||
|
}
|
||||||
|
if (value.maximum) {
|
||||||
|
zodType = zodType.max(value.maximum);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'boolean':
|
||||||
|
zodType = z.boolean();
|
||||||
|
break;
|
||||||
|
case 'object':
|
||||||
|
const schemaCopy = structuredClone(schema);
|
||||||
|
schemaCopy.properties.traits.properties = value.properties!;
|
||||||
|
zodType = generateZodSchema(schemaCopy);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
zodType = z.any();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.required) {
|
||||||
|
zodType = zodType.nullable();
|
||||||
|
}
|
||||||
|
|
||||||
|
zodSchema.extend({ [key]: zodType });
|
||||||
|
}
|
||||||
|
|
||||||
|
return zodSchema;
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue