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 (
+
+
+ );
+}
+
+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 (
-
-
- );
-}
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.
+
+
+
+
Link
+
+
+ {
+ toast.info('Link copied to clipboard');
+ navigator.clipboard.writeText(dialogLink);
+ }}
+ >
+
+
+
+
+ {
+ dialogCode ?
+ 'The user will need this link to access the recovery flow.'
+ :
+ 'This magic link will authenticate the user automatically'
+ }
+
+
+ {
+ dialogCode ?
+
+
Code
+
+
+ {
+ toast.info('Code copied to clipboard');
+ navigator.clipboard.writeText(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
+
+
+
+ This has to be valid JSON
+
+ )}
+ />
+ (
+
+ Admin Metadata
+
+
+
+ This has to be valid JSON
+
+ )}
+ />
+
+ );
+}
diff --git a/dashboard/src/components/ui/alert-dialog.tsx b/dashboard/src/components/ui/alert-dialog.tsx
index d65d9d1..c6ec9fb 100644
--- a/dashboard/src/components/ui/alert-dialog.tsx
+++ b/dashboard/src/components/ui/alert-dialog.tsx
@@ -104,7 +104,7 @@ const AlertDialogAction = React.forwardRef<
>(({ className, ...props }, ref) => (
));
diff --git a/dashboard/src/components/ui/textarea.tsx b/dashboard/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..fcbdc98
--- /dev/null
+++ b/dashboard/src/components/ui/textarea.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<'textarea'>
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+Textarea.displayName = 'Textarea';
+
+export { Textarea };
diff --git a/dashboard/src/lib/action/identity.ts b/dashboard/src/lib/action/identity.ts
new file mode 100644
index 0000000..7497f3c
--- /dev/null
+++ b/dashboard/src/lib/action/identity.ts
@@ -0,0 +1,130 @@
+'use server';
+
+import { getIdentityApi } from '@/ory/sdk/server';
+import { revalidatePath } from 'next/cache';
+import { DeleteIdentityCredentialsTypeEnum, UpdateIdentityBody } from '@ory/client';
+
+interface UpdatedIdentityProps {
+ id: string;
+ body: UpdateIdentityBody;
+}
+
+export async function updateIdentity({ id, body }: UpdatedIdentityProps) {
+
+ const identityApi = await getIdentityApi();
+ const { data } = await identityApi.updateIdentity({
+ id: id,
+ updateIdentityBody: body,
+ });
+
+ console.log('Updated identity', data);
+
+ revalidatePath('/user');
+
+ return data;
+}
+
+interface DeleteIdentityCredentialProps {
+ id: string;
+ type: DeleteIdentityCredentialsTypeEnum;
+}
+
+export async function deleteIdentityCredential({ id, type }: DeleteIdentityCredentialProps) {
+
+ const identityApi = await getIdentityApi();
+ const { data } = await identityApi.deleteIdentityCredentials({ id, type });
+
+ console.log('Credential removed', data);
+
+ revalidatePath('/user');
+
+ return data;
+}
+
+export async function deleteIdentitySessions(id: string) {
+
+ const identityApi = await getIdentityApi();
+ const { data } = await identityApi.deleteIdentitySessions({ id });
+
+ console.log('Deleted identity\'s sessions', data);
+
+ revalidatePath('/user');
+
+ return data;
+}
+
+export async function createRecoveryCode(id: string) {
+
+ const identityApi = await getIdentityApi();
+ const { data } = await identityApi.createRecoveryCodeForIdentity({
+ createRecoveryCodeForIdentityBody: {
+ identity_id: id,
+ },
+ });
+
+ console.log('Created recovery code for user', id, data);
+
+ return data;
+}
+
+export async function createRecoveryLink(id: string) {
+
+ const identityApi = await getIdentityApi();
+ const { data } = await identityApi.createRecoveryLinkForIdentity({
+ createRecoveryLinkForIdentityBody: {
+ identity_id: id,
+ },
+ });
+
+ console.log('Created recovery link for user', id, data);
+
+ return data;
+}
+
+export async function blockIdentity(id: string) {
+
+ 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: string) {
+
+ 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: string) {
+
+ const identityApi = await getIdentityApi();
+ const { data } = await identityApi.deleteIdentity({ id });
+
+ console.log('Deleted identity', data);
+
+ revalidatePath('/user');
+}
diff --git a/dashboard/src/lib/forms/identity-form.ts b/dashboard/src/lib/forms/identity-form.ts
index c891884..c15f4c2 100644
--- a/dashboard/src/lib/forms/identity-form.ts
+++ b/dashboard/src/lib/forms/identity-form.ts
@@ -1,22 +1,39 @@
+'use client';
+
import { z } from 'zod';
-// interface for a list of properties
+export const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
+type Literal = z.infer;
+type Json = Literal | { [key: string]: Json } | Json[];
+export const jsonSchema: z.ZodType = z.lazy(() =>
+ z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]),
+);
+
+// Interface for a list of properties
export interface KratosSchemaProperties {
[key: string]: {
- type: string;
+ type: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null';
format?: string;
title: string;
minLength?: number;
maxLength?: number;
minimum?: number;
maximum?: number;
- required?: boolean;
+ required: boolean;
description?: string;
- properties?: KratosSchemaProperties
+ properties?: KratosSchemaProperties;
+ enum?: any[];
+ pattern?: string;
+ items?: KratosSchemaProperties;
+ oneOf?: KratosSchemaProperties[];
+ anyOf?: KratosSchemaProperties[];
+ allOf?: KratosSchemaProperties[];
+ dependencies?: { [key: string]: string[] | KratosSchemaProperties };
+ additionalProperties?: boolean;
};
}
-// interface for the kratos identity schema
+// Interface for the Kratos identity schema
export interface KratosSchema {
$id: string;
$schema: string;
@@ -32,53 +49,50 @@ export interface KratosSchema {
};
}
-export function generateZodSchema(properties: KratosSchemaProperties) {
+export function kratosSchemaToZod(schema: KratosSchema): z.ZodObject {
- const zodSchema = z.object({});
+ // Function to recursively convert Kratos properties to Zod types
+ function convertProperties(properties: KratosSchemaProperties): { [key: string]: z.ZodTypeAny } {
+ const zodProps: { [key: string]: z.ZodTypeAny } = {};
+ for (const key in properties) {
+ const prop = properties[key];
- 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();
+ let zodType;
+ switch (prop.type) {
+ case 'string':
+ zodType = z.string();
+ if (prop.format === 'email') zodType = zodType.email();
+ if (prop.minLength) zodType = zodType.min(prop.minLength);
+ if (prop.maxLength) zodType = zodType.max(prop.maxLength);
+ if (prop.pattern) zodType = zodType.regex(new RegExp(prop.pattern));
+ break;
+ case 'number':
+ case 'integer':
+ zodType = z.number();
+ if (prop.minimum) zodType = zodType.min(prop.minimum);
+ if (prop.maximum) zodType = zodType.max(prop.maximum);
+ break;
+ case 'boolean':
+ zodType = z.boolean();
+ break;
+ case 'object':
+ zodType = z.object(convertProperties(prop.properties || {}));
+ break;
+ case 'array':
+ zodType = z.array(
+ prop.items ? kratosSchemaToZod({ properties: { item: prop.items } } as any).shape.item : z.any(),
+ );
+ break;
+ default:
+ zodType = z.any(); // Fallback to any for unknown types
+ }
+
+ if (prop.enum) zodType = zodType.refine((val) => prop.enum!.includes(val));
+
+ zodProps[key] = zodType;
}
-
- if (!value.required) {
- zodType = zodType.nullable();
- }
-
- zodSchema.extend({ [key]: zodType });
+ return zodProps;
}
- return zodSchema;
+ return z.object(convertProperties(schema.properties.traits.properties));
}
diff --git a/docker/ory-dev/generate-users.sh b/docker/ory-dev/generate-users.sh
new file mode 100644
index 0000000..559b2d6
--- /dev/null
+++ b/docker/ory-dev/generate-users.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+
+# Check if the number of arguments is correct
+if [ $# -ne 2 ]; then
+ echo "Usage: $0 "
+ exit 1
+fi
+
+# Get the schema ID and count from the arguments
+schema_id=$1
+count=$2
+
+# Loop through the count
+for i in $(seq 1 $count); do
+
+ # Create the JSON data with the email and name
+ data=$(cat <