NORY-47: add edit ability to identity traits (#49)
This commit is contained in:
commit
49ec50e3df
16 changed files with 858 additions and 244 deletions
Binary file not shown.
|
@ -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",
|
||||
|
|
|
@ -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
|
|||
<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>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<Card className="row-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Traits</CardTitle>
|
||||
<CardDescription>All identity properties specified in the identity schema</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<IdentityTraitForm schema={identitySchema} identity={identity}/>
|
||||
<IdentityTraits schema={identitySchema} identity={identity}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actions</CardTitle>
|
||||
<CardDescription>Quick actions to manage the identity</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<IdentityActions identity={identity}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
|
@ -183,26 +191,7 @@ export default async function UserDetailsPage({ params }: { params: Promise<{ id
|
|||
<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>
|
||||
<IdentityCredentials identity={identity}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
|
|
|
@ -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');
|
||||
}
|
|
@ -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
|
|||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => deleteIdentitySessions({ id: currentIdentity.id })}>
|
||||
Invalidate sessions
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: 'destructive' })}
|
||||
onClick={() => deleteIdentitySessions(currentIdentity.id)}>
|
||||
Invalidate sessions
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
@ -280,13 +282,13 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent
|
|||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction
|
||||
onClick={() => blockIdentity({ id: currentIdentity.id })}>
|
||||
Block identity
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => blockIdentity(currentIdentity.id)}>
|
||||
Block identity
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
@ -302,13 +304,13 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent
|
|||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction
|
||||
onClick={() => unblockIdentity({ id: currentIdentity.id })}>
|
||||
Unblock identity
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => unblockIdentity(currentIdentity.id)}>
|
||||
Unblock identity
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
@ -325,13 +327,14 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent
|
|||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteIdentity({ id: currentIdentity.id })}>
|
||||
Delete identity
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: 'destructive' })}
|
||||
onClick={() => deleteIdentity(currentIdentity.id)}>
|
||||
Delete identity
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
|
74
dashboard/src/components/confirmation-dialog-wrapper.tsx
Normal file
74
dashboard/src/components/confirmation-dialog-wrapper.tsx
Normal file
|
@ -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<typeof buttonVariants>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ConfirmationDialogWrapper(
|
||||
{
|
||||
onCancel,
|
||||
onSubmit,
|
||||
tooltipContent,
|
||||
dialogTitle,
|
||||
dialogDescription,
|
||||
dialogButtonCancel,
|
||||
dialogButtonSubmit,
|
||||
dialogButtonSubmitProps,
|
||||
children,
|
||||
}: ButtonWithConfirmDialogProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipContent className={tooltipContent ? '' : 'hidden'}>
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{dialogTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{dialogDescription}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>
|
||||
{dialogButtonCancel ?? 'Cancel'}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onSubmit}
|
||||
className={buttonVariants({ ...dialogButtonSubmitProps })}>
|
||||
{dialogButtonSubmit ?? 'Confirm'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
99
dashboard/src/components/dynamic-form.tsx
Normal file
99
dashboard/src/components/dynamic-form.tsx
Normal file
|
@ -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<T extends FieldValues> {
|
||||
children?: ReactNode,
|
||||
form: UseFormReturn<T>,
|
||||
properties: KratosSchemaProperties,
|
||||
onValid: SubmitHandler<T>,
|
||||
onInvalid: SubmitErrorHandler<T>,
|
||||
submitLabel?: string,
|
||||
}
|
||||
|
||||
export function DynamicForm<T extends FieldValues>(
|
||||
{
|
||||
children,
|
||||
form,
|
||||
properties,
|
||||
onValid,
|
||||
onInvalid,
|
||||
submitLabel,
|
||||
}: DynamicFormProps<T>,
|
||||
) {
|
||||
|
||||
const generateFormFields = (data: KratosSchemaProperties, prefix = '') => {
|
||||
return (
|
||||
<React.Fragment key={prefix}>
|
||||
{
|
||||
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 (
|
||||
<FormField
|
||||
{...form.register(fullFieldName as Path<T>)}
|
||||
key={fullFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<Checkbox {...field} checked={field.value}/>
|
||||
<FormLabel>{key}</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
} else {
|
||||
|
||||
return (
|
||||
<FormField
|
||||
{...form.register(fullFieldName as Path<T>)}
|
||||
key={fullFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{value.title}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={value.title} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{value.description}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className="grid grid-cols-1 gap-2" onSubmit={form.handleSubmit(onValid, onInvalid)}>
|
||||
{generateFormFields(properties)}
|
||||
{children}
|
||||
<Button
|
||||
key="submit"
|
||||
type="submit"
|
||||
disabled={!form.formState.isDirty}
|
||||
>
|
||||
{submitLabel ?? 'Submit'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicForm;
|
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
223
dashboard/src/components/identity/identity-actions.tsx
Normal file
223
dashboard/src/components/identity/identity-actions.tsx
Normal file
|
@ -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<string>('');
|
||||
const [dialogCode, setDialogCode] = useState<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog open={dialogVisible} onOpenChange={(value) => setDialogVisible(value)}>
|
||||
<AlertDialogContent className="space-y-1">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Recovery account</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You created a recovery flow. Provide the user with the following information so they can
|
||||
access their account again.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div>
|
||||
<Label>Link</Label>
|
||||
<div className="flex relative">
|
||||
<Input value={dialogLink} readOnly/>
|
||||
<Button
|
||||
className="absolute right-0"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
toast.info('Link copied to clipboard');
|
||||
navigator.clipboard.writeText(dialogLink);
|
||||
}}
|
||||
>
|
||||
<Copy/>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{
|
||||
dialogCode ?
|
||||
'The user will need this link to access the recovery flow.'
|
||||
:
|
||||
'This magic link will authenticate the user automatically'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
{
|
||||
dialogCode ?
|
||||
<div>
|
||||
<Label>Code</Label>
|
||||
<div className="flex relative">
|
||||
<Input value={dialogCode} readOnly/>
|
||||
<Button
|
||||
className="absolute right-0"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
toast.info('Code copied to clipboard');
|
||||
navigator.clipboard.writeText(dialogCode);
|
||||
}}
|
||||
>
|
||||
<Copy/>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
The user will need to enter this code on the recovery page.
|
||||
</p>
|
||||
</div>
|
||||
:
|
||||
<></>
|
||||
}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction className={buttonVariants({ variant: 'destructive' })}>
|
||||
Close
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<ConfirmationDialogWrapper
|
||||
onSubmit={async () => {
|
||||
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"
|
||||
>
|
||||
<Button className="mr-2" size="icon">
|
||||
<Key className="h-4"/>
|
||||
</Button>
|
||||
</ConfirmationDialogWrapper>
|
||||
|
||||
<ConfirmationDialogWrapper
|
||||
onSubmit={async () => {
|
||||
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"
|
||||
>
|
||||
<Button className="mr-2" size="icon">
|
||||
<Link className="h-4"/>
|
||||
</Button>
|
||||
</ConfirmationDialogWrapper>
|
||||
|
||||
{
|
||||
identity.state === 'active' ?
|
||||
<ConfirmationDialogWrapper
|
||||
onSubmit={async () => {
|
||||
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"
|
||||
>
|
||||
<Button className="mr-2" size="icon">
|
||||
<UserX className="h-4"/>
|
||||
</Button>
|
||||
</ConfirmationDialogWrapper>
|
||||
:
|
||||
<ConfirmationDialogWrapper
|
||||
onSubmit={async () => {
|
||||
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"
|
||||
>
|
||||
<Button className="mr-2" size="icon">
|
||||
<UserCheck className="h-4"/>
|
||||
</Button>
|
||||
</ConfirmationDialogWrapper>
|
||||
}
|
||||
|
||||
<ConfirmationDialogWrapper
|
||||
onSubmit={async () => {
|
||||
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' }}
|
||||
>
|
||||
<Button className="mr-2" size="icon">
|
||||
<UserMinus className="h-4"/>
|
||||
</Button>
|
||||
</ConfirmationDialogWrapper>
|
||||
|
||||
<ConfirmationDialogWrapper
|
||||
onSubmit={async () => {
|
||||
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' }}
|
||||
>
|
||||
<Button className="mr-2" size="icon">
|
||||
<Trash className="h-4"/>
|
||||
</Button>
|
||||
</ConfirmationDialogWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
61
dashboard/src/components/identity/identity-credentials.tsx
Normal file
61
dashboard/src/components/identity/identity-credentials.tsx
Normal file
|
@ -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 (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{
|
||||
Object.entries(identity.credentials!).map(([key, value]) => {
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell>{key}</TableCell>
|
||||
<TableCell>{value.identifiers![0]}</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
Object.values(DeleteIdentityCredentialsTypeEnum).includes(key as DeleteIdentityCredentialsTypeEnum) &&
|
||||
key !== 'password' && key !== 'code' &&
|
||||
(
|
||||
<ConfirmationDialogWrapper
|
||||
onSubmit={async () => {
|
||||
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' }}>
|
||||
<Button size="icon" variant="outline">
|
||||
<Trash className="h-4"/>
|
||||
</Button>
|
||||
</ConfirmationDialogWrapper>
|
||||
)
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
110
dashboard/src/components/identity/identity-traits.tsx
Normal file
110
dashboard/src/components/identity/identity-traits.tsx
Normal file
|
@ -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<z.infer<typeof zodIdentitySchema>>({
|
||||
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<typeof zodIdentitySchema>) => {
|
||||
|
||||
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<typeof zodIdentitySchema>) => {
|
||||
console.log('data', data);
|
||||
toast.error('Invalid values');
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm
|
||||
form={form}
|
||||
properties={schema.properties.traits.properties}
|
||||
onValid={onValid}
|
||||
onInvalid={onInvalid}
|
||||
submitLabel="Update Identity"
|
||||
>
|
||||
<FormField
|
||||
{...form.register('metadata_public')}
|
||||
key={'metadata_public'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Public Metadata</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Public Metadata" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>This has to be valid JSON</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
{...form.register('metadata_admin')}
|
||||
key={'metadata_admin'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Admin Metadata</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Admin Metadata" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>This has to be valid JSON</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</DynamicForm>
|
||||
);
|
||||
}
|
|
@ -104,7 +104,7 @@ const AlertDialogAction = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
className={(cn(buttonVariants(), className))}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
22
dashboard/src/components/ui/textarea.tsx
Normal file
22
dashboard/src/components/ui/textarea.tsx
Normal file
|
@ -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
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
130
dashboard/src/lib/action/identity.ts
Normal file
130
dashboard/src/lib/action/identity.ts
Normal file
|
@ -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');
|
||||
}
|
|
@ -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<typeof literalSchema>;
|
||||
type Json = Literal | { [key: string]: Json } | Json[];
|
||||
export const jsonSchema: z.ZodType<Json> = 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<any> {
|
||||
|
||||
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));
|
||||
}
|
||||
|
|
36
docker/ory-dev/generate-users.sh
Normal file
36
docker/ory-dev/generate-users.sh
Normal file
|
@ -0,0 +1,36 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Check if the number of arguments is correct
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Usage: $0 <schema_id> <count>"
|
||||
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 <<EOF
|
||||
{
|
||||
"schema_id": "$schema_id",
|
||||
"state": "active",
|
||||
"traits": {
|
||||
"email": "user-$i@example.com",
|
||||
"name": "User $i"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Execute the curl command
|
||||
curl --request POST \
|
||||
--url http://127.0.0.1:4434/admin/identities \
|
||||
--data "$data"
|
||||
|
||||
done
|
||||
|
||||
exit 0
|
Loading…
Add table
Reference in a new issue