diff --git a/dashboard/bun.lockb b/dashboard/bun.lockb index af3b2c6..ec6a87b 100755 Binary files a/dashboard/bun.lockb and b/dashboard/bun.lockb differ diff --git a/dashboard/package.json b/dashboard/package.json index 569f906..4e18553 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -43,7 +43,8 @@ "tailwindcss-animate": "^1.0.7", "ua-parser-js": "^2.0.0", "usehooks-ts": "^3.1.0", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zod_utilz": "^0.8.3" }, "devDependencies": { "@types/node": "^22.9.3", diff --git a/dashboard/src/app/(inside)/user/[id]/page.tsx b/dashboard/src/app/(inside)/user/[id]/page.tsx index 44806a3..83cc3b3 100644 --- a/dashboard/src/app/(inside)/user/[id]/page.tsx +++ b/dashboard/src/app/(inside)/user/[id]/page.tsx @@ -2,13 +2,15 @@ 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 { IdentityTraits } from '@/components/identity/identity-traits'; 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'; +import { IdentityActions } from '@/components/identity/identity-actions'; +import { IdentityCredentials } from '@/components/identity/identity-credentials'; interface MergedAddress { recovery_id?: string; @@ -87,10 +89,7 @@ export default async function UserDetailsPage({ params }: { params: Promise<{ id }); const sessions = await identityApi.listIdentitySessions({ id: identityId }) - .then((response) => { - console.log('sessions', response.data); - return response.data; - }) + .then((response) => response.data) .catch(() => { console.log('No sessions found'); }); @@ -122,14 +121,23 @@ export default async function UserDetailsPage({ params }: { params: Promise<{ id

{addresses[0].value}

{identity.id}

-
- +
+ Traits All identity properties specified in the identity schema - + + + + + + Actions + Quick actions to manage the identity + + + @@ -183,26 +191,7 @@ export default async function UserDetailsPage({ params }: { params: Promise<{ id All authentication mechanisms registered with this identity - - - - Type - Value - - - - { - Object.entries(identity.credentials!).map(([key, value]) => { - return ( - - {key} - {value.identifiers![0]} - - ); - }) - } - -
+
diff --git a/dashboard/src/app/(inside)/user/action.ts b/dashboard/src/app/(inside)/user/action.ts deleted file mode 100644 index 4c393cc..0000000 --- a/dashboard/src/app/(inside)/user/action.ts +++ /dev/null @@ -1,66 +0,0 @@ -'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'); -} diff --git a/dashboard/src/app/(inside)/user/data-table.tsx b/dashboard/src/app/(inside)/user/data-table.tsx index 39f9ae1..8f4e3ac 100644 --- a/dashboard/src/app/(inside)/user/data-table.tsx +++ b/dashboard/src/app/(inside)/user/data-table.tsx @@ -15,7 +15,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Button } from '@/components/ui/button'; +import { Button, buttonVariants } from '@/components/ui/button'; import Link from 'next/link'; import { toast } from 'sonner'; import { @@ -28,7 +28,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { blockIdentity, deleteIdentity, deleteIdentitySessions, unblockIdentity } from '@/app/(inside)/user/action'; +import { blockIdentity, deleteIdentity, deleteIdentitySessions, unblockIdentity } from '@/lib/action/identity'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; interface IdentityDataTableProps { @@ -259,12 +259,14 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent - deleteIdentitySessions({ id: currentIdentity.id })}> - Invalidate sessions - Cancel + deleteIdentitySessions(currentIdentity.id)}> + Invalidate sessions + @@ -280,13 +282,13 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent - blockIdentity({ id: currentIdentity.id })}> - Block identity - Cancel + blockIdentity(currentIdentity.id)}> + Block identity + @@ -302,13 +304,13 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent - unblockIdentity({ id: currentIdentity.id })}> - Unblock identity - Cancel + unblockIdentity(currentIdentity.id)}> + Unblock identity + @@ -325,13 +327,14 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent - deleteIdentity({ id: currentIdentity.id })}> - Delete identity - Cancel + deleteIdentity(currentIdentity.id)}> + Delete identity + diff --git a/dashboard/src/components/confirmation-dialog-wrapper.tsx b/dashboard/src/components/confirmation-dialog-wrapper.tsx new file mode 100644 index 0000000..2d40093 --- /dev/null +++ b/dashboard/src/components/confirmation-dialog-wrapper.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { ButtonProps, buttonVariants } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { ReactNode } from 'react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import type { VariantProps } from 'class-variance-authority'; + +interface ButtonWithConfirmDialogProps { + buttonProps?: ButtonProps; + onCancel?: () => any; + onSubmit: () => any; + tooltipContent?: string; + dialogTitle: string; + dialogDescription: string; + dialogButtonCancel?: string; + dialogButtonSubmit?: string; + dialogButtonSubmitProps?: VariantProps; + children: ReactNode; +} + +export function ConfirmationDialogWrapper( + { + onCancel, + onSubmit, + tooltipContent, + dialogTitle, + dialogDescription, + dialogButtonCancel, + dialogButtonSubmit, + dialogButtonSubmitProps, + children, + }: ButtonWithConfirmDialogProps) { + return ( + + + {tooltipContent} + + + + + {children} + + + + + {dialogTitle} + {dialogDescription} + + + + {dialogButtonCancel ?? 'Cancel'} + + + {dialogButtonSubmit ?? 'Confirm'} + + + + + + ); +} \ No newline at end of file diff --git a/dashboard/src/components/dynamic-form.tsx b/dashboard/src/components/dynamic-form.tsx new file mode 100644 index 0000000..9020d46 --- /dev/null +++ b/dashboard/src/components/dynamic-form.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { FieldValues, Path, SubmitErrorHandler, SubmitHandler, UseFormReturn } from 'react-hook-form'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import React, { ReactNode } from 'react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { KratosSchemaProperties } from '@/lib/forms/identity-form'; + +interface DynamicFormProps { + children?: ReactNode, + form: UseFormReturn, + properties: KratosSchemaProperties, + onValid: SubmitHandler, + onInvalid: SubmitErrorHandler, + submitLabel?: string, +} + +export function DynamicForm( + { + children, + form, + properties, + onValid, + onInvalid, + submitLabel, + }: DynamicFormProps, +) { + + const generateFormFields = (data: KratosSchemaProperties, prefix = '') => { + return ( + + { + data && Object.entries(data).map(([key, value]) => { + + const fullFieldName = prefix ? `${prefix}.${key}` : key; + + if (value.type === 'object') { + + return generateFormFields(value.properties!, fullFieldName); + + } else if (value.type === 'boolean') { + + return ( + )} + key={fullFieldName} + render={({ field }) => ( + + + {key} + + )} + /> + ); + + } else { + + return ( + )} + key={fullFieldName} + render={({ field }) => ( + + {value.title} + + + + {value.description} + + )} + /> + ); + } + }) + } + + ); + }; + + return ( +
+ + {generateFormFields(properties)} + {children} + +
+ + ); +} + +export default DynamicForm; diff --git a/dashboard/src/components/forms/IdentityTraitForm.tsx b/dashboard/src/components/forms/IdentityTraitForm.tsx deleted file mode 100644 index 8f863d8..0000000 --- a/dashboard/src/components/forms/IdentityTraitForm.tsx +++ /dev/null @@ -1,82 +0,0 @@ -'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 ( - ( - - - {value.title} - - )} - /> - ); - } else { - return ( - ( - - {value.title} - - - - - )} - /> - ); - } - }, - ); -} - -export function IdentityTraitForm({ schema, identity }: IdentityTraitFormProps) { - - const zodIdentitySchema = generateZodSchema(schema); - const form = useForm>({ - defaultValues: identity.traits, - resolver: zodResolver(zodIdentitySchema), - }); - - function onSubmit(values: z.infer) { - toast.message(JSON.stringify(values, null, 4)); - } - - return ( -
- - { - renderUiNodes(form, schema.properties.traits.properties) - } -
- - ); -} diff --git a/dashboard/src/components/identity/identity-actions.tsx b/dashboard/src/components/identity/identity-actions.tsx new file mode 100644 index 0000000..0ddcf32 --- /dev/null +++ b/dashboard/src/components/identity/identity-actions.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { Identity } from '@ory/client'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { Copy, Key, Link, Trash, UserCheck, UserMinus, UserX } from 'lucide-react'; +import { ConfirmationDialogWrapper } from '@/components/confirmation-dialog-wrapper'; +import { + blockIdentity, + createRecoveryCode, + createRecoveryLink, + deleteIdentity, + deleteIdentitySessions, + unblockIdentity, +} from '@/lib/action/identity'; +import { toast } from 'sonner'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +interface IdentityActionProps { + identity: Identity; +} + +export function IdentityActions({ identity }: IdentityActionProps, +) { + + const router = useRouter(); + + const [dialogVisible, setDialogVisible] = useState(false); + const [dialogLink, setDialogLink] = useState(''); + const [dialogCode, setDialogCode] = useState(undefined); + + return ( + <> + setDialogVisible(value)}> + + + Recovery account + + You created a recovery flow. Provide the user with the following information so they can + access their account again. + + +
+ +
+ + +
+

+ { + dialogCode ? + 'The user will need this link to access the recovery flow.' + : + 'This magic link will authenticate the user automatically' + } +

+
+ { + dialogCode ? +
+ +
+ + +
+

+ The user will need to enter this code on the recovery page. +

+
+ : + <> + } + + + Close + + +
+
+ + { + await createRecoveryCode(identity.id) + .then((response) => { + setDialogLink(response.recovery_link); + setDialogCode(response.recovery_code); + setDialogVisible(true); + }) + .catch(() => toast.error('Creating recovery code failed')); + }} + tooltipContent="Create recovery code" + dialogTitle="Create recovery code" + dialogDescription="Are you sure you want to create a recovery code for this identity?" + dialogButtonSubmit="Create code" + > + + + + { + await createRecoveryLink(identity.id) + .then((response) => { + setDialogLink(response.recovery_link); + setDialogCode(undefined); + setDialogVisible(true); + }) + .catch(() => toast.error('Creating recovery link failed. It is likely magic-links are disabled on your Ory Kratos instance.')); + }} + tooltipContent="Create recovery link" + dialogTitle="Create recovery link" + dialogDescription="Are you sure you want to create a recovery link for this identity?" + dialogButtonSubmit="Create link" + > + + + + { + identity.state === 'active' ? + { + await blockIdentity(identity.id) + .then(() => toast.success('Identity deactivated')) + .catch(() => toast.error('Deactivating identity failed')); + }} + tooltipContent="Deactivate identity" + dialogTitle="Deactivate identity" + dialogDescription="Are you sure you want to deactivate this identity? The user will not be able to sign-in or use any active session until re-activation!" + dialogButtonSubmit="Deactivate" + > + + + : + { + await unblockIdentity(identity.id) + .then(() => toast.success('Identity activated')) + .catch(() => toast.error('Activating identity failed')); + }} + tooltipContent="Activate identity" + dialogTitle="Activate identity" + dialogDescription="Are you sure you want to activate this identity?" + dialogButtonSubmit="Activate" + > + + + } + + { + await deleteIdentitySessions(identity.id) + .then(() => toast.success('All sessions invalidated')) + .catch(() => toast.error('Invalidating all sessions failed')); + }} + tooltipContent="Invalidate all sessions" + dialogTitle="Invalidate all sessions" + dialogDescription="Are you sure you want to invalidate and delete ALL session of this identity? This action is irreversible!" + dialogButtonSubmit="Invalidate sessions" + dialogButtonSubmitProps={{ variant: 'destructive' }} + > + + + + { + await deleteIdentity(identity.id) + .then(() => { + toast.success('Identity deleted'); + router.push('/user'); + }) + .catch(() => toast.error('Deleting identity failed')); + }} + tooltipContent="Delete identity" + dialogTitle="Delete identity" + dialogDescription="Are you sure you want to delete this identity? This action is irreversible!" + dialogButtonSubmit="Delete identity" + dialogButtonSubmitProps={{ variant: 'destructive' }} + > + + + + ); +} \ No newline at end of file diff --git a/dashboard/src/components/identity/identity-credentials.tsx b/dashboard/src/components/identity/identity-credentials.tsx new file mode 100644 index 0000000..f362971 --- /dev/null +++ b/dashboard/src/components/identity/identity-credentials.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { ConfirmationDialogWrapper } from '@/components/confirmation-dialog-wrapper'; +import { deleteIdentityCredential } from '@/lib/action/identity'; +import { Button } from '@/components/ui/button'; +import { Trash } from 'lucide-react'; +import { DeleteIdentityCredentialsTypeEnum, Identity } from '@ory/client'; +import { toast } from 'sonner'; + +interface IdentityCredentialsProps { + identity: Identity; +} + +export function IdentityCredentials({ identity }: IdentityCredentialsProps) { + return ( + + + + Type + Value + + + + + { + Object.entries(identity.credentials!).map(([key, value]) => { + return ( + + {key} + {value.identifiers![0]} + + { + Object.values(DeleteIdentityCredentialsTypeEnum).includes(key as DeleteIdentityCredentialsTypeEnum) && + key !== 'password' && key !== 'code' && + ( + { + deleteIdentityCredential({ id: identity.id, type: key as never }) + .then(() => toast.success(`Credential ${key} deleted`)) + .catch(() => toast.error(`Deleting credential ${key} failed`)); + }} + dialogTitle="Delete credential" + dialogDescription={`Are you sure you want to remove the credential of type ${key} from this identity?`} + dialogButtonSubmit={`Delete ${key}`} + dialogButtonSubmitProps={{ variant: 'destructive' }}> + + + ) + } + + + ); + }) + } + +
+ ); +} \ No newline at end of file diff --git a/dashboard/src/components/identity/identity-traits.tsx b/dashboard/src/components/identity/identity-traits.tsx new file mode 100644 index 0000000..1606db4 --- /dev/null +++ b/dashboard/src/components/identity/identity-traits.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { KratosSchema, kratosSchemaToZod } from '@/lib/forms/identity-form'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Identity } from '@ory/client'; +import { toast } from 'sonner'; +import DynamicForm from '@/components/dynamic-form'; +import { FormControl, FormDescription, FormField, FormItem, FormLabel } from '@/components/ui/form'; +import { Textarea } from '@/components/ui/textarea'; +import { zu } from 'zod_utilz'; +import { updateIdentity } from '@/lib/action/identity'; +import { useState } from 'react'; + +interface IdentityTraitFormProps { + schema: KratosSchema; + identity: Identity; +} + +export function IdentityTraits({ schema, identity }: IdentityTraitFormProps) { + + const [currentIdentity, setCurrentIdentity] = useState(identity); + + const generated = kratosSchemaToZod(schema); + const metadata = z.object({ + metadata_public: zu.stringToJSON(), + metadata_admin: zu.stringToJSON(), + }); + + const zodIdentitySchema = generated.merge(metadata); + + const form = useForm>({ + resolver: zodResolver(zodIdentitySchema), + defaultValues: { + ...currentIdentity.traits, + metadata_public: currentIdentity.metadata_public ? + JSON.stringify(currentIdentity.metadata_public) : '{}', + metadata_admin: currentIdentity.metadata_admin ? + JSON.stringify(currentIdentity.metadata_admin) : '{}', + }, + }); + + const onValid = (data: z.infer) => { + + const traits = structuredClone(data); + delete traits['metadata_public']; + delete traits['metadata_admin']; + + updateIdentity({ + id: currentIdentity.id, + body: { + schema_id: currentIdentity.schema_id, + state: currentIdentity.state!, + traits: traits, + metadata_public: data.metadata_public, + metadata_admin: data.metadata_admin, + }, + }) + .then((identity) => { + setCurrentIdentity(identity); + toast.success('Identity updated'); + }) + .catch(() => { + toast.error('Updating identity failed'); + }); + }; + + const onInvalid = (data: z.infer) => { + console.log('data', data); + toast.error('Invalid values'); + }; + + return ( + + ( + + Public Metadata + +