diff --git a/README.md b/README.md index 8a10ca6..3eeceaf 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ # Next-Ory -Get started with the Ory stack quickly and easily. +Get started with ORY authentication quickly and easily. > [!Warning] > This project is work in progress. There is no guarantee that everything will work as it should and breaking changes in > the future are possible. -The goal of this project is to create an easy-to-use setup to self-host the [Ory](https://www.ory.sh) stack with all its -components. It will contain an authentication UI, implementing all self-service flows for Ory Kratos and Ory Hydra, as -well as an admin UI. All UI components are written in NextJS and Typescript, and styled using shadcn/ui and TailwindCSS. +The goal of this project is to create an easy-to-use setup to self-host [Ory Kratos](https://www.ory.sh/kratos) +and [Ory Hydra](https://www.ory.sh/hydra). It will contain an authentication UI, implementing all self-service flows for +Ory Kratos and Ory Hydra, as well as an admin UI. All UI components are written in NextJS and Typescript, and styled +using shadcn/ui and TailwindCSS. ## Getting started @@ -31,9 +32,6 @@ bun install bun run dev ``` -Create an account using the authentication UI on http://localhost:3000. -The verification code can be found on the dummy SMTP dashboard on http://localhost:4436. - Inside another terminal session we can start the dashboard UI: ```bash @@ -43,15 +41,6 @@ bun install bun run dev ``` -To access the admin dashboard, the `identity` has to be a `member` of the `admin` role. (Relation: roles:admin@< -identity_id>#member)
-The identity ID is displayed on the screen when accessing the dashboard without sufficient permissions.
-Use the identity ID to execute the following script with the identity ID as an argument. - -```bash -sh docker/ory-dev/keto-make-admin.sh -``` - ## Deployment *soon.* @@ -69,10 +58,11 @@ Hydra. It is implemented in a way, that customizing style and page layout is ver ## Admin Dashboard -Right now I am working on the admin dashboard for all Ory applications. It will provide you with an overview of your -instances and let you manage users, OAuth2 applications and more. It is ***work in progress*** and should be handled -with caution. +Right now I am working on the admin dashboard for Ory Kratos. It will provide you with an overview of your instance and +let you manage users, OAuth2 applications and more. It is ***work in progress*** and should not be used in anything +important as it is not yet protected by Keto permissions but only by a valid Kratos session! ![A browser window showing the home page of the dashboard UI in dark mode](./documentation/.img/d-dashboard-dark.png) ![A browser window showing the users page of the dashboard UI in dark mode](./documentation/.img/d-users-dark.png) + diff --git a/authentication/README.md b/authentication/README.md index 40979b3..0e6613c 100644 --- a/authentication/README.md +++ b/authentication/README.md @@ -1,6 +1,6 @@ # Next-Ory - Authentication -This directory contains a NextJS 15 (app router) UI Node, implementing all Ory Kratos and Ory Hydra UI flows. +This directory contains a NextJS 14 (app router) UI Node, implementing all Ory Kratos and Ory Hydra UI flows. ## Stack diff --git a/authentication/src/app/flow/error/page.tsx b/authentication/src/app/flow/error/page.tsx index fa87dc4..e35626f 100644 --- a/authentication/src/app/flow/error/page.tsx +++ b/authentication/src/app/flow/error/page.tsx @@ -51,7 +51,7 @@ export default function Error() { return ( <> - + An error occurred @@ -61,7 +61,7 @@ export default function Error() {

- - - ); -} diff --git a/dashboard/src/app/application/page.tsx b/dashboard/src/app/application/page.tsx new file mode 100644 index 0000000..eda2025 --- /dev/null +++ b/dashboard/src/app/application/page.tsx @@ -0,0 +1,3 @@ +export default async function ApplicationPage() { + return <>; +} diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx index 3cbf18b..8fda46d 100644 --- a/dashboard/src/app/layout.tsx +++ b/dashboard/src/app/layout.tsx @@ -2,15 +2,20 @@ import type { Viewport } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; import { cn } from '@/lib/utils'; +import { Toaster } from '@/components/ui/sonner'; import React from 'react'; import { ThemeProvider } from 'next-themes'; +import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/app-sidebar'; +import { Separator } from '@/components/ui/separator'; +import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb'; const inter = Inter({ subsets: ['latin'] }); const APP_NAME = 'Next Ory'; const APP_DEFAULT_TITLE = 'Next Ory'; const APP_TITLE_TEMPLATE = `%s | ${APP_DEFAULT_TITLE}`; -const APP_DESCRIPTION = 'Get started with Ory authentication quickly and easily.'; +const APP_DESCRIPTION = 'Get started with ORY authentication quickly and easily.'; export const metadata = { applicationName: APP_NAME, @@ -51,7 +56,31 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac enableSystem disableTransitionOnChange > - {children} + + + +
+ + + { + // TODO: implement dynamic Breadcrumbs + } + + + + + Ory Dashboard + + + + +
+
+ {children} +
+
+ +
diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx new file mode 100644 index 0000000..72fa7dc --- /dev/null +++ b/dashboard/src/app/page.tsx @@ -0,0 +1,81 @@ +import { getHydraMetadataApi, getKratosMetadataApi } from '@/ory/sdk/server'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; + +export default async function RootPage() { + + const kratosMetadataApi = await getKratosMetadataApi(); + + const kratosVersion = await kratosMetadataApi + .getVersion() + .then(res => res.data.version) + .catch(() => ''); + + const kratosStatusData = await fetch(process.env.ORY_KRATOS_ADMIN_URL + '/health/alive'); + const kratosStatus = await kratosStatusData.json() as { status: string }; + + const kratosDBStatusData = await fetch(process.env.ORY_KRATOS_ADMIN_URL + '/health/ready'); + const kratosDBStatus = await kratosDBStatusData.json() as { status: string }; + + const hydraMetadataApi = await getHydraMetadataApi(); + + const hydraVersion = await hydraMetadataApi + .getVersion() + .then(res => res.data.version) + .catch(() => ''); + + const hydraStatusData = await fetch(process.env.ORY_KRATOS_ADMIN_URL + '/health/alive'); + const hydraStatus = await hydraStatusData.json() as { status: string }; + + const hydraDBStatusData = await fetch(process.env.ORY_KRATOS_ADMIN_URL + '/health/ready'); + const hydraDBStatus = await hydraDBStatusData.json() as { status: string }; + + return ( +
+
+

Software Stack

+

See the list of all applications in your stack

+
+
+ + + + Ory Kratos + + + Version {kratosVersion} + + + + + Kratos {kratosStatus.status.toUpperCase()} + + + Database {kratosDBStatus.status.toUpperCase()} + + + + + + + Ory Hydra + + + Version {hydraVersion} + + + + + Hydra {hydraStatus.status.toUpperCase()} + + + Database {hydraDBStatus.status.toUpperCase()} + + + +
+
+
+
+ ); +} diff --git a/dashboard/src/app/service-worker.ts b/dashboard/src/app/service-worker.ts index b9741f4..0538ba3 100644 --- a/dashboard/src/app/service-worker.ts +++ b/dashboard/src/app/service-worker.ts @@ -1,24 +1,18 @@ +import type { PrecacheEntry } from '@serwist/precaching'; +import { installSerwist } from '@serwist/sw'; import { defaultCache } from '@serwist/next/worker'; -import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'; -import { Serwist } from 'serwist'; -declare const self: ServiceWorkerGlobalScope; +declare const self: ServiceWorkerGlobalScope & { + // Change this attribute's name to your `injectionPoint`. + // `injectionPoint` is an InjectManifest option. + // See https://serwist.pages.dev/docs/build/inject-manifest/configuring + __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; +}; -declare global { - interface WorkerGlobalScope extends SerwistGlobalConfig { - // Change this attribute's name to your `injectionPoint`. - // `injectionPoint` is an InjectManifest option. - // See https://serwist.pages.dev/docs/build/configuring - __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; - } -} - -const serwist = new Serwist({ +installSerwist({ precacheEntries: self.__SW_MANIFEST, skipWaiting: true, clientsClaim: true, navigationPreload: true, runtimeCaching: defaultCache, }); - -serwist.addEventListeners(); diff --git a/dashboard/src/app/(inside)/user/[id]/page.tsx b/dashboard/src/app/user/[id]/page.tsx similarity index 87% rename from dashboard/src/app/(inside)/user/[id]/page.tsx rename to dashboard/src/app/user/[id]/page.tsx index 83cc3b3..44806a3 100644 --- a/dashboard/src/app/(inside)/user/[id]/page.tsx +++ b/dashboard/src/app/user/[id]/page.tsx @@ -2,15 +2,13 @@ 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 { IdentityTraits } from '@/components/identity/identity-traits'; +import { IdentityTraitForm } from '@/components/forms/IdentityTraitForm'; import { KratosSchema } from '@/lib/forms/identity-form'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { UAParser } from 'ua-parser-js'; import { RecoveryIdentityAddress, VerifiableIdentityAddress } from '@ory/client'; import { Badge } from '@/components/ui/badge'; import { Check, X } from 'lucide-react'; -import { IdentityActions } from '@/components/identity/identity-actions'; -import { IdentityCredentials } from '@/components/identity/identity-credentials'; interface MergedAddress { recovery_id?: string; @@ -89,7 +87,10 @@ export default async function UserDetailsPage({ params }: { params: Promise<{ id }); const sessions = await identityApi.listIdentitySessions({ id: identityId }) - .then((response) => response.data) + .then((response) => { + console.log('sessions', response.data); + return response.data; + }) .catch(() => { console.log('No sessions found'); }); @@ -121,23 +122,14 @@ 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 - - - + @@ -191,7 +183,26 @@ 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/user/action.ts b/dashboard/src/app/user/action.ts new file mode 100644 index 0000000..4c393cc --- /dev/null +++ b/dashboard/src/app/user/action.ts @@ -0,0 +1,66 @@ +'use server'; + +import { getIdentityApi } from '@/ory/sdk/server'; +import { revalidatePath } from 'next/cache'; + +interface IdentityIdProps { + id: string; +} + +export async function deleteIdentitySessions({ id }: IdentityIdProps) { + + const identityApi = await getIdentityApi(); + const { data } = await identityApi.deleteIdentitySessions({ id }); + + console.log('Deleted identity\'s sessions', data); + + return data; +} + +export async function blockIdentity({ id }: IdentityIdProps) { + + const identityApi = await getIdentityApi(); + const { data } = await identityApi.patchIdentity({ + id, + jsonPatch: [ + { + op: 'replace', + path: '/state', + value: 'inactive', + }, + ], + }); + + console.log('Blocked identity', data); + + revalidatePath('/user'); +} + +export async function unblockIdentity({ id }: IdentityIdProps) { + + const identityApi = await getIdentityApi(); + const { data } = await identityApi.patchIdentity({ + id, + jsonPatch: [ + { + op: 'replace', + path: '/state', + value: 'active', + }, + ], + }); + + console.log('Unblocked identity', data); + + revalidatePath('/user'); +} + +export async function deleteIdentity({ id }: IdentityIdProps) { + + const identityApi = await getIdentityApi(); + const { data } = await identityApi.deleteIdentity({ id }); + + console.log('Deleted identity', data); + + revalidatePath('/user'); +} diff --git a/dashboard/src/app/(inside)/user/data-table.tsx b/dashboard/src/app/user/data-table.tsx similarity index 78% rename from dashboard/src/app/(inside)/user/data-table.tsx rename to dashboard/src/app/user/data-table.tsx index 510ee6c..5819331 100644 --- a/dashboard/src/app/(inside)/user/data-table.tsx +++ b/dashboard/src/app/user/data-table.tsx @@ -4,7 +4,10 @@ import { ColumnDef } from '@tanstack/react-table'; import { Identity } from '@ory/client'; import { DataTable } from '@/components/ui/data-table'; import { CircleCheck, CircleX, Copy, MoreHorizontal, Trash, UserCheck, UserMinus, UserPen, UserX } from 'lucide-react'; -import React, { useEffect, useState } from 'react'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; +import React, { useEffect, useRef, useState } from 'react'; +import { FetchIdentityPageProps } from '@/app/user/page'; +import { Spinner } from '@/components/ui/spinner'; import { DropdownMenu, DropdownMenuContent, @@ -13,7 +16,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Button, buttonVariants } from '@/components/ui/button'; +import { Button } from '@/components/ui/button'; import Link from 'next/link'; import { toast } from 'sonner'; import { @@ -26,16 +29,17 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { blockIdentity, deleteIdentity, deleteIdentitySessions, unblockIdentity } from '@/lib/action/identity'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { blockIdentity, deleteIdentity, deleteIdentitySessions, unblockIdentity } from '@/app/user/action'; interface IdentityDataTableProps { data: Identity[]; - page: number; + pageSize: number; + pageToken: string | undefined; query: string; + fetchIdentityPage: (props: FetchIdentityPageProps) => Promise<{ data: Identity[], tokens: Map }>; } -export function IdentityDataTable({ data, page, query }: IdentityDataTableProps) { +export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdentityPage }: IdentityDataTableProps) { const columns: ColumnDef[] = [ { @@ -77,24 +81,27 @@ export function IdentityDataTable({ data, page, query }: IdentityDataTableProps) return (
{email.value} - - - { - email.verified ? : - } - - - { - email.verified ? - <> - This email was confirmed at - {new Date(email.verified_at!!).toLocaleString()} - - : -

This email is not confirmed yet

- } -
-
+ { + email.verified ? + + + + + + This email was confirmed at + {new Date(email.verified_at!!).toLocaleString()} + + + : + + + + + + This email is not confirmed yet + + + }
); } @@ -182,10 +189,49 @@ export function IdentityDataTable({ data, page, query }: IdentityDataTableProps) ]; const [items, setItems] = useState(data); + const [nextToken, setNextToken] = useState(pageToken); + // react on changes from ssr (query params) useEffect(() => { setItems(data); - }, [data]); + setNextToken(pageToken); + }, [data, pageSize, pageToken, query]); + + // infinite scroll handling + const infiniteScrollSensor = useRef(null); + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + fetchMore(); + } + }, + { threshold: 0.5 }, // Adjust threshold as needed + ); + + if (infiniteScrollSensor.current) { + observer.observe(infiniteScrollSensor.current); + } + + return () => { + if (infiniteScrollSensor.current) { + observer.unobserve(infiniteScrollSensor.current); + } + }; + }, [items]); + + const fetchMore = async () => { + if (!nextToken) return; + + const response = await fetchIdentityPage({ + pageSize: pageSize, + pageToken: nextToken, + query: query, + }); + + setItems([...items, ...response.data]); + setNextToken(response.tokens.get('next') ?? undefined); + }; // quick actions const [currentIdentity, setCurrentIdentity] = useState(undefined); @@ -213,14 +259,12 @@ export function IdentityDataTable({ data, page, query }: IdentityDataTableProps) + deleteIdentitySessions({ id: currentIdentity.id })}> + Invalidate sessions + Cancel - deleteIdentitySessions(currentIdentity.id)}> - Invalidate sessions - @@ -236,13 +280,13 @@ export function IdentityDataTable({ data, page, query }: IdentityDataTableProps) + blockIdentity({ id: currentIdentity.id })}> + Block identity + Cancel - blockIdentity(currentIdentity.id)}> - Block identity - @@ -258,13 +302,13 @@ export function IdentityDataTable({ data, page, query }: IdentityDataTableProps) + unblockIdentity({ id: currentIdentity.id })}> + Unblock identity + Cancel - unblockIdentity(currentIdentity.id)}> - Unblock identity - @@ -281,20 +325,26 @@ export function IdentityDataTable({ data, page, query }: IdentityDataTableProps) + deleteIdentity({ id: currentIdentity.id })}> + Delete identity + Cancel - deleteIdentity(currentIdentity.id)}> - Delete identity - ) } + { + nextToken && ( +
+ +
+ ) + } ); } diff --git a/dashboard/src/app/user/page.tsx b/dashboard/src/app/user/page.tsx new file mode 100644 index 0000000..6fe3108 --- /dev/null +++ b/dashboard/src/app/user/page.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { IdentityDataTable } from '@/app/user/data-table'; +import { getIdentityApi } from '@/ory/sdk/server'; +import { SearchInput } from '@/components/search-input'; + +export interface FetchIdentityPageProps { + pageSize: number; + pageToken: string; + query: string; +} + +async function fetchIdentityPage({ pageSize, pageToken, query }: FetchIdentityPageProps) { + 'use server'; + + const identityApi = await getIdentityApi(); + const response = await identityApi.listIdentities({ + pageSize: pageSize, + pageToken: pageToken, + previewCredentialsIdentifierSimilar: query, + }); + + return { + data: response.data, + tokens: parseTokens(response.headers.link), + }; +} + +function parseTokens(link: string) { + + const parsed = link.split(',').map((it) => { + const startRel = it.lastIndexOf('rel="'); + const endRel = it.lastIndexOf('"'); + const rel = it.slice(startRel, endRel); + + const startToken = it.lastIndexOf('page_token='); + const endToken = it.lastIndexOf('&'); + const token = it.slice(startToken, endToken); + + return [rel, token]; + }); + + return new Map(parsed.map(obj => [ + obj[0].replace('rel="', ''), + obj[1].replace('page_token=', ''), + ])); +} + +export default async function UserPage( + { + searchParams, + }: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }> + }, +) { + + const params = await searchParams; + const query = params.query ? params.query as string : ''; + + let pageSize = 250; + let pageToken: string = '00000000-0000-0000-0000-000000000000'; + + const initialFetch = await fetchIdentityPage({ pageSize, pageToken, query: query }); + + return ( +
+
+

Users

+

+ See and manage all identities registered with your Ory Kratos instance +

+
+
+ + +
+
+ ); +} diff --git a/dashboard/src/components/app-sidebar.tsx b/dashboard/src/components/app-sidebar.tsx index ff21f4d..7039c7f 100644 --- a/dashboard/src/components/app-sidebar.tsx +++ b/dashboard/src/components/app-sidebar.tsx @@ -5,13 +5,14 @@ import { SidebarContent, SidebarFooter, SidebarGroup, + SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, - useSidebar, } from '@/components/ui/sidebar'; -import { AppWindow, ChartLine, FileLock2, Home, LogOut, LucideIcon, Moon, Sun, Users } from 'lucide-react'; +import { AppWindow, Home, LogOut, Moon, Sun, Users } from 'lucide-react'; +import React from 'react'; import { DropdownMenu, DropdownMenuContent, @@ -20,126 +21,50 @@ import { } from '@/components/ui/dropdown-menu'; import { useTheme } from 'next-themes'; import { LogoutLink } from '@/ory'; -import React from 'react'; import Link from 'next/link'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; - -interface SidebarGroup { - type: 'group'; - label: string; - items: SidebarItem[]; -} - -interface SidebarItem { - type: 'item'; - label: string; - path: string; - icon: LucideIcon; -} - -type SidebarContent = SidebarItem | SidebarGroup - -function renderSidebarMenuItem(item: SidebarItem, key: number, collapsed: boolean) { - return ( - - - - - - - {item.label} - - - - - - {item.label} - - - ); -} export function AppSidebar({ ...props }: React.ComponentProps) { const { setTheme } = useTheme(); - const { state } = useSidebar(); - - const items: SidebarContent[] = [ + const items = [ { - label: 'Application', - type: 'group', - items: [ - { - type: 'item', - label: 'Home', - path: '/', - icon: Home, - }, - { - type: 'item', - label: 'Analytics', - path: '/analytics', - icon: ChartLine, - }, - ], + title: 'Home', + url: '/', + icon: Home, }, { - label: 'Ory Kratos', - type: 'group', - items: [ - { - type: 'item', - label: 'Users', - path: '/user', - icon: Users, - }, - ], + title: 'Users', + url: '/user', + icon: Users, }, { - label: 'Ory Hydra', - type: 'group', - items: [ - { - type: 'item', - label: 'Clients', - path: '/client', - icon: AppWindow, - }, - ], - }, - { - label: 'Ory Keto', - type: 'group', - items: [ - { - type: 'item', - label: 'Relations', - path: '/relation', - icon: FileLock2, - }, - ], + title: 'Applications', + url: '/application', + icon: AppWindow, }, ]; return ( - - {items.map((item, index) => { - switch (item.type) { - case 'item': - return renderSidebarMenuItem(item, index, state === 'collapsed'); - case 'group': - return ( - - {item.label} - {item.items.map((subItem, subIndex) => renderSidebarMenuItem(subItem, subIndex, state === 'collapsed'))} - - ); - } - })} - + + Application + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + diff --git a/dashboard/src/components/confirmation-dialog-wrapper.tsx b/dashboard/src/components/confirmation-dialog-wrapper.tsx deleted file mode 100644 index 2d40093..0000000 --- a/dashboard/src/components/confirmation-dialog-wrapper.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'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 deleted file mode 100644 index 9020d46..0000000 --- a/dashboard/src/components/dynamic-form.tsx +++ /dev/null @@ -1,99 +0,0 @@ -'use client'; - -import { FieldValues, Path, SubmitErrorHandler, SubmitHandler, UseFormReturn } from 'react-hook-form'; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import React, { ReactNode } from 'react'; -import { Checkbox } from '@/components/ui/checkbox'; -import { KratosSchemaProperties } from '@/lib/forms/identity-form'; - -interface DynamicFormProps { - children?: ReactNode, - form: UseFormReturn, - properties: KratosSchemaProperties, - onValid: SubmitHandler, - onInvalid: SubmitErrorHandler, - submitLabel?: string, -} - -export function DynamicForm( - { - children, - form, - properties, - onValid, - onInvalid, - submitLabel, - }: DynamicFormProps, -) { - - const generateFormFields = (data: KratosSchemaProperties, prefix = '') => { - return ( - - { - data && Object.entries(data).map(([key, value]) => { - - const fullFieldName = prefix ? `${prefix}.${key}` : key; - - if (value.type === 'object') { - - return generateFormFields(value.properties!, fullFieldName); - - } else if (value.type === 'boolean') { - - return ( - )} - key={fullFieldName} - render={({ field }) => ( - - - {key} - - )} - /> - ); - - } else { - - return ( - )} - key={fullFieldName} - render={({ field }) => ( - - {value.title} - - - - {value.description} - - )} - /> - ); - } - }) - } - - ); - }; - - return ( -
- - {generateFormFields(properties)} - {children} - -
- - ); -} - -export default DynamicForm; diff --git a/dashboard/src/components/error.tsx b/dashboard/src/components/error.tsx index 5e5ac12..e264180 100644 --- a/dashboard/src/components/error.tsx +++ b/dashboard/src/components/error.tsx @@ -3,11 +3,11 @@ interface ErrorDisplayProps { message: string; } -export function ErrorDisplay({ title, message }: ErrorDisplayProps) { +export async function ErrorDisplay({ title, message }: ErrorDisplayProps) { return ( - <> +

{title}

{message}

- +
); } \ No newline at end of file diff --git a/dashboard/src/components/forms/IdentityTraitForm.tsx b/dashboard/src/components/forms/IdentityTraitForm.tsx new file mode 100644 index 0000000..8f863d8 --- /dev/null +++ b/dashboard/src/components/forms/IdentityTraitForm.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { generateZodSchema, KratosSchema, KratosSchemaProperties } from '@/lib/forms/identity-form'; +import { useForm, UseFormReturn } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { Identity } from '@ory/client'; +import { Checkbox } from '@/components/ui/checkbox'; + +interface IdentityTraitFormProps { + schema: KratosSchema; + identity: Identity; +} + +function renderUiNodes(form: UseFormReturn, properties: KratosSchemaProperties, prefix?: string): any { + + let keyPrefix = prefix ? prefix + '.' : ''; + + return Object.entries(properties).map(([key, value]) => { + if (value.type === 'object') { + return renderUiNodes(form, value.properties!, key); + } else if (value.type === 'boolean') { + return ( + ( + + + {value.title} + + )} + /> + ); + } else { + return ( + ( + + {value.title} + + + + + )} + /> + ); + } + }, + ); +} + +export function IdentityTraitForm({ schema, identity }: IdentityTraitFormProps) { + + const zodIdentitySchema = generateZodSchema(schema); + const form = useForm>({ + defaultValues: identity.traits, + resolver: zodResolver(zodIdentitySchema), + }); + + function onSubmit(values: z.infer) { + toast.message(JSON.stringify(values, null, 4)); + } + + return ( +
+ + { + renderUiNodes(form, schema.properties.traits.properties) + } +
+ + ); +} diff --git a/dashboard/src/components/identity/identity-actions.tsx b/dashboard/src/components/identity/identity-actions.tsx deleted file mode 100644 index 0ddcf32..0000000 --- a/dashboard/src/components/identity/identity-actions.tsx +++ /dev/null @@ -1,223 +0,0 @@ -'use client'; - -import { Identity } from '@ory/client'; -import { Button, buttonVariants } from '@/components/ui/button'; -import { Copy, Key, Link, Trash, UserCheck, UserMinus, UserX } from 'lucide-react'; -import { ConfirmationDialogWrapper } from '@/components/confirmation-dialog-wrapper'; -import { - blockIdentity, - createRecoveryCode, - createRecoveryLink, - deleteIdentity, - deleteIdentitySessions, - unblockIdentity, -} from '@/lib/action/identity'; -import { toast } from 'sonner'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; - -interface IdentityActionProps { - identity: Identity; -} - -export function IdentityActions({ identity }: IdentityActionProps, -) { - - const router = useRouter(); - - const [dialogVisible, setDialogVisible] = useState(false); - const [dialogLink, setDialogLink] = useState(''); - const [dialogCode, setDialogCode] = useState(undefined); - - return ( - <> - setDialogVisible(value)}> - - - Recovery account - - You created a recovery flow. Provide the user with the following information so they can - access their account again. - - -
- -
- - -
-

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

-
- { - dialogCode ? -
- -
- - -
-

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

-
- : - <> - } - - - Close - - -
-
- - { - await createRecoveryCode(identity.id) - .then((response) => { - setDialogLink(response.recovery_link); - setDialogCode(response.recovery_code); - setDialogVisible(true); - }) - .catch(() => toast.error('Creating recovery code failed')); - }} - tooltipContent="Create recovery code" - dialogTitle="Create recovery code" - dialogDescription="Are you sure you want to create a recovery code for this identity?" - dialogButtonSubmit="Create code" - > - - - - { - await createRecoveryLink(identity.id) - .then((response) => { - setDialogLink(response.recovery_link); - setDialogCode(undefined); - setDialogVisible(true); - }) - .catch(() => toast.error('Creating recovery link failed. It is likely magic-links are disabled on your Ory Kratos instance.')); - }} - tooltipContent="Create recovery link" - dialogTitle="Create recovery link" - dialogDescription="Are you sure you want to create a recovery link for this identity?" - dialogButtonSubmit="Create link" - > - - - - { - identity.state === 'active' ? - { - await blockIdentity(identity.id) - .then(() => toast.success('Identity deactivated')) - .catch(() => toast.error('Deactivating identity failed')); - }} - tooltipContent="Deactivate identity" - dialogTitle="Deactivate identity" - dialogDescription="Are you sure you want to deactivate this identity? The user will not be able to sign-in or use any active session until re-activation!" - dialogButtonSubmit="Deactivate" - > - - - : - { - await unblockIdentity(identity.id) - .then(() => toast.success('Identity activated')) - .catch(() => toast.error('Activating identity failed')); - }} - tooltipContent="Activate identity" - dialogTitle="Activate identity" - dialogDescription="Are you sure you want to activate this identity?" - dialogButtonSubmit="Activate" - > - - - } - - { - await deleteIdentitySessions(identity.id) - .then(() => toast.success('All sessions invalidated')) - .catch(() => toast.error('Invalidating all sessions failed')); - }} - tooltipContent="Invalidate all sessions" - dialogTitle="Invalidate all sessions" - dialogDescription="Are you sure you want to invalidate and delete ALL session of this identity? This action is irreversible!" - dialogButtonSubmit="Invalidate sessions" - dialogButtonSubmitProps={{ variant: 'destructive' }} - > - - - - { - await deleteIdentity(identity.id) - .then(() => { - toast.success('Identity deleted'); - router.push('/user'); - }) - .catch(() => toast.error('Deleting identity failed')); - }} - tooltipContent="Delete identity" - dialogTitle="Delete identity" - dialogDescription="Are you sure you want to delete this identity? This action is irreversible!" - dialogButtonSubmit="Delete identity" - dialogButtonSubmitProps={{ variant: 'destructive' }} - > - - - - ); -} \ No newline at end of file diff --git a/dashboard/src/components/identity/identity-credentials.tsx b/dashboard/src/components/identity/identity-credentials.tsx deleted file mode 100644 index f362971..0000000 --- a/dashboard/src/components/identity/identity-credentials.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'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 deleted file mode 100644 index 1606db4..0000000 --- a/dashboard/src/components/identity/identity-traits.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'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 - -