mirror of
https://codeberg.org/MarkusThielker/next-ory.git
synced 2025-04-10 11:58:41 +00:00
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",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ua-parser-js": "^2.0.0",
|
"ua-parser-js": "^2.0.0",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4",
|
||||||
|
"zod_utilz": "^0.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.9.3",
|
"@types/node": "^22.9.3",
|
||||||
|
|
|
@ -2,13 +2,15 @@ import React from 'react';
|
||||||
import { getIdentityApi } from '@/ory/sdk/server';
|
import { getIdentityApi } from '@/ory/sdk/server';
|
||||||
import { ErrorDisplay } from '@/components/error';
|
import { ErrorDisplay } from '@/components/error';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { KratosSchema } from '@/lib/forms/identity-form';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
import { RecoveryIdentityAddress, VerifiableIdentityAddress } from '@ory/client';
|
import { RecoveryIdentityAddress, VerifiableIdentityAddress } from '@ory/client';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Check, X } from 'lucide-react';
|
import { Check, X } from 'lucide-react';
|
||||||
|
import { IdentityActions } from '@/components/identity/identity-actions';
|
||||||
|
import { IdentityCredentials } from '@/components/identity/identity-credentials';
|
||||||
|
|
||||||
interface MergedAddress {
|
interface MergedAddress {
|
||||||
recovery_id?: string;
|
recovery_id?: string;
|
||||||
|
@ -87,10 +89,7 @@ export default async function UserDetailsPage({ params }: { params: Promise<{ id
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessions = await identityApi.listIdentitySessions({ id: identityId })
|
const sessions = await identityApi.listIdentitySessions({ id: identityId })
|
||||||
.then((response) => {
|
.then((response) => response.data)
|
||||||
console.log('sessions', response.data);
|
|
||||||
return response.data;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
console.log('No sessions found');
|
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-3xl font-bold leading-tight tracking-tight">{addresses[0].value}</p>
|
||||||
<p className="text-lg font-light">{identity.id}</p>
|
<p className="text-lg font-light">{identity.id}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
<Card>
|
<Card className="row-span-3">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Traits</CardTitle>
|
<CardTitle>Traits</CardTitle>
|
||||||
<CardDescription>All identity properties specified in the identity schema</CardDescription>
|
<CardDescription>All identity properties specified in the identity schema</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
@ -183,26 +191,7 @@ export default async function UserDetailsPage({ params }: { params: Promise<{ id
|
||||||
<CardDescription>All authentication mechanisms registered with this identity</CardDescription>
|
<CardDescription>All authentication mechanisms registered with this identity</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<Table>
|
<IdentityCredentials identity={identity}/>
|
||||||
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<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,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
|
@ -28,7 +28,7 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} 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';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
interface IdentityDataTableProps {
|
interface IdentityDataTableProps {
|
||||||
|
@ -259,12 +259,14 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogAction onClick={() => deleteIdentitySessions({ id: currentIdentity.id })}>
|
|
||||||
Invalidate sessions
|
|
||||||
</AlertDialogAction>
|
|
||||||
<AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={buttonVariants({ variant: 'destructive' })}
|
||||||
|
onClick={() => deleteIdentitySessions(currentIdentity.id)}>
|
||||||
|
Invalidate sessions
|
||||||
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
@ -280,13 +282,13 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => blockIdentity({ id: currentIdentity.id })}>
|
|
||||||
Block identity
|
|
||||||
</AlertDialogAction>
|
|
||||||
<AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => blockIdentity(currentIdentity.id)}>
|
||||||
|
Block identity
|
||||||
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
@ -302,13 +304,13 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => unblockIdentity({ id: currentIdentity.id })}>
|
|
||||||
Unblock identity
|
|
||||||
</AlertDialogAction>
|
|
||||||
<AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => unblockIdentity(currentIdentity.id)}>
|
||||||
|
Unblock identity
|
||||||
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
@ -325,13 +327,14 @@ export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdent
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => deleteIdentity({ id: currentIdentity.id })}>
|
|
||||||
Delete identity
|
|
||||||
</AlertDialogAction>
|
|
||||||
<AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={buttonVariants({ variant: 'destructive' })}
|
||||||
|
onClick={() => deleteIdentity(currentIdentity.id)}>
|
||||||
|
Delete identity
|
||||||
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Action
|
<AlertDialogPrimitive.Action
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(buttonVariants(), className)}
|
className={(cn(buttonVariants(), className))}
|
||||||
{...props}
|
{...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';
|
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 {
|
export interface KratosSchemaProperties {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
type: string;
|
type: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null';
|
||||||
format?: string;
|
format?: string;
|
||||||
title: string;
|
title: string;
|
||||||
minLength?: number;
|
minLength?: number;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
minimum?: number;
|
minimum?: number;
|
||||||
maximum?: number;
|
maximum?: number;
|
||||||
required?: boolean;
|
required: boolean;
|
||||||
description?: string;
|
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 {
|
export interface KratosSchema {
|
||||||
$id: string;
|
$id: string;
|
||||||
$schema: 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;
|
||||||
let zodType;
|
switch (prop.type) {
|
||||||
switch (value.type) {
|
case 'string':
|
||||||
case 'string':
|
zodType = z.string();
|
||||||
zodType = z.string();
|
if (prop.format === 'email') zodType = zodType.email();
|
||||||
if (value.format === 'email') {
|
if (prop.minLength) zodType = zodType.min(prop.minLength);
|
||||||
zodType = z.string().email();
|
if (prop.maxLength) zodType = zodType.max(prop.maxLength);
|
||||||
}
|
if (prop.pattern) zodType = zodType.regex(new RegExp(prop.pattern));
|
||||||
if (value.minLength) {
|
break;
|
||||||
zodType = zodType.min(value.minLength);
|
case 'number':
|
||||||
}
|
case 'integer':
|
||||||
if (value.maxLength) {
|
zodType = z.number();
|
||||||
zodType = zodType.max(value.maxLength);
|
if (prop.minimum) zodType = zodType.min(prop.minimum);
|
||||||
}
|
if (prop.maximum) zodType = zodType.max(prop.maximum);
|
||||||
break;
|
break;
|
||||||
case 'integer':
|
case 'boolean':
|
||||||
case 'number':
|
zodType = z.boolean();
|
||||||
zodType = z.number();
|
break;
|
||||||
if (value.minimum) {
|
case 'object':
|
||||||
zodType = zodType.min(value.minimum);
|
zodType = z.object(convertProperties(prop.properties || {}));
|
||||||
}
|
break;
|
||||||
if (value.maximum) {
|
case 'array':
|
||||||
zodType = zodType.max(value.maximum);
|
zodType = z.array(
|
||||||
}
|
prop.items ? kratosSchemaToZod({ properties: { item: prop.items } } as any).shape.item : z.any(),
|
||||||
break;
|
);
|
||||||
case 'boolean':
|
break;
|
||||||
zodType = z.boolean();
|
default:
|
||||||
break;
|
zodType = z.any(); // Fallback to any for unknown types
|
||||||
case 'object':
|
}
|
||||||
const schemaCopy = structuredClone(schema);
|
|
||||||
schemaCopy.properties.traits.properties = value.properties!;
|
if (prop.enum) zodType = zodType.refine((val) => prop.enum!.includes(val));
|
||||||
zodType = generateZodSchema(schemaCopy);
|
|
||||||
break;
|
zodProps[key] = zodType;
|
||||||
default:
|
|
||||||
zodType = z.any();
|
|
||||||
}
|
}
|
||||||
|
return zodProps;
|
||||||
if (!value.required) {
|
|
||||||
zodType = zodType.nullable();
|
|
||||||
}
|
|
||||||
|
|
||||||
zodSchema.extend({ [key]: zodType });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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