diff --git a/dashboard/bun.lockb b/dashboard/bun.lockb index 457965a..4148b6f 100755 Binary files a/dashboard/bun.lockb and b/dashboard/bun.lockb differ diff --git a/dashboard/package.json b/dashboard/package.json index 9641b8b..589ca60 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -14,13 +14,15 @@ "@ory/integrations": "^1.1.5", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-checkbox": "^1.0.4", - "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.4", "@serwist/next": "^9.0.0-preview.21", @@ -30,6 +32,7 @@ "@tanstack/react-table": "^8.20.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", + "cmdk": "1.0.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.38.3", "lucide-react": "^0.462.0", diff --git a/dashboard/src/app/(inside)/client/create/page.tsx b/dashboard/src/app/(inside)/client/create/page.tsx new file mode 100644 index 0000000..3aa3c9a --- /dev/null +++ b/dashboard/src/app/(inside)/client/create/page.tsx @@ -0,0 +1,28 @@ +import { CreateClientForm } from '@/components/forms/client-form'; +import { createClient } from '@/lib/action/client'; +import { checkPermission, requireSession } from '@/lib/action/authentication'; +import { permission, relation } from '@/lib/permission'; +import { redirect } from 'next/navigation'; + +export default async function CreateClientPage() { + + const session = await requireSession(); + const identityId = session.identity!.id; + + const pmCreateClient = await checkPermission(permission.client.it, relation.create, identityId); + if (!pmCreateClient) { + return redirect('/client'); + } + + return ( +
+
+

Create OAuth2 Client

+

+ Configure your new OAuth2 Client. +

+
+ +
+ ); +} \ No newline at end of file diff --git a/dashboard/src/app/(inside)/client/page.tsx b/dashboard/src/app/(inside)/client/page.tsx index 0d3f5a3..42e0092 100644 --- a/dashboard/src/app/(inside)/client/page.tsx +++ b/dashboard/src/app/(inside)/client/page.tsx @@ -1,4 +1,9 @@ import { getOAuth2Api } from '@/ory/sdk/server'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { checkPermission, requireSession } from '@/lib/action/authentication'; +import { permission, relation } from '@/lib/permission'; +import InsufficientPermission from '@/components/insufficient-permission'; import { ClientDataTable } from '@/app/(inside)/client/data-table'; export interface FetchClientPageProps { @@ -29,6 +34,12 @@ function parseTokens(link: string) { async function fetchClientPage({ pageSize, pageToken }: FetchClientPageProps) { 'use server'; + const session = await requireSession(); + const allowed = await checkPermission(permission.client.it, relation.access, session.identity!.id); + if (!allowed) { + throw Error('Unauthorised'); + } + const oAuth2Api = await getOAuth2Api(); const response = await oAuth2Api.listOAuth2Clients({ pageSize: pageSize, @@ -43,24 +54,49 @@ async function fetchClientPage({ pageSize, pageToken }: FetchClientPageProps) { export default async function ListClientPage() { + const session = await requireSession(); + const identityId = session.identity!.id; + + const pmAccessClient = await checkPermission(permission.client.it, relation.access, identityId); + const pmCreateClient = await checkPermission(permission.client.it, relation.create, identityId); + let pageSize = 100; let pageToken: string = '00000000-0000-0000-0000-000000000000'; - const initialFetch = await fetchClientPage({ pageSize, pageToken }); + const initialFetch = pmAccessClient && await fetchClientPage({ pageSize, pageToken }); return (
-
+

OAuth2 Clients

See and manage all OAuth2 clients registered with your Ory Hydra instance

+ { + pmCreateClient && ( + + ) + }
- + { + pmAccessClient ? + ( + initialFetch && + ) + : + + }
); } diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css index 262da57..65bc799 100644 --- a/dashboard/src/app/globals.css +++ b/dashboard/src/app/globals.css @@ -75,4 +75,15 @@ body { @apply bg-sidebar text-foreground; } +} + + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + } } \ No newline at end of file diff --git a/dashboard/src/components/forms/client-form.tsx b/dashboard/src/components/forms/client-form.tsx new file mode 100644 index 0000000..0ca84b6 --- /dev/null +++ b/dashboard/src/components/forms/client-form.tsx @@ -0,0 +1,588 @@ +'use client'; + +import { z } from 'zod'; +import { clientFormSchema } from '@/lib/forms/client-form'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useState } from 'react'; +import { OAuth2Client } from '@ory/client'; +import { AxiosResponse } from 'axios'; +import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useRouter } from 'next/navigation'; +import { Minus } from 'lucide-react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import Link from 'next/link'; + +interface CreateClientFormProps { + action: (data: z.infer) => Promise>; +} + +export function CreateClientForm({ action }: CreateClientFormProps) { + + const router = useRouter(); + + const [redirectUris, setRedirectUris] = useState(['']); + const [postLogoutRedirectUris, setPostLogoutRedirectUris] = useState(['']); + + const form = useForm>({ + resolver: zodResolver(clientFormSchema), + defaultValues: { + client_name: '', + scope: '', + redirect_uris: [''], + skip: false, + logo_uri: '', + policy_uri: '', + tos_uri: '', + owner: '', + }, + }); + + const [successDialogOpen, setSuccessDialogOpen] = useState(false); + const [createdClient, setCreatedClient] = useState(); + const handleSubmit = async (data: z.infer) => { + await action(data) + .then((response) => { + console.log(response); + return response.data; + }) + .then((client) => { + setCreatedClient(client); + setSuccessDialogOpen(true); + }) + .catch((error) => { + console.error(error); + }); + }; + + const addRedirectUri = () => { + setRedirectUris([...redirectUris, '']); + }; + + const addPostLogoutRedirectUri = () => { + setPostLogoutRedirectUris([...postLogoutRedirectUris, '']); + }; + + const removeRedirectUri = (index: number) => { + const updatedRedirectUris = redirectUris.filter((_, i) => i !== index); + setRedirectUris(updatedRedirectUris); + form.setValue('redirect_uris', updatedRedirectUris); + }; + + const removePostLogoutRedirectUri = (index: number) => { + const updatedPostLogoutRedirectUris = postLogoutRedirectUris.filter((_, i) => i !== index); + setPostLogoutRedirectUris(postLogoutRedirectUris); + form.setValue('post_logout_redirect_uris', updatedPostLogoutRedirectUris); + }; + + const handleInputChange = (index: number, event: any) => { + const updatedRedirectUris = [...redirectUris]; + updatedRedirectUris[index] = event.target.value; + setRedirectUris(updatedRedirectUris); + form.setValue('redirect_uris', updatedRedirectUris); + }; + + const handlePostLogoutInputChange = (index: number, event: any) => { + const updatedPostLogoutRedirectUris = [...postLogoutRedirectUris]; + updatedPostLogoutRedirectUris[index] = event.target.value; + setPostLogoutRedirectUris(updatedPostLogoutRedirectUris); + form.setValue('post_logout_redirect_uris', updatedPostLogoutRedirectUris); + }; + + return ( + <> + { + createdClient && ( + setSuccessDialogOpen(false)}> + + + Client created + + Your client was created successfully. Make sure to safe the client secret! + + + + ) + } +
+ + + + + + Essentials + + + + ( + + Client Name + + The human-readable name of the client to be presented to the end-user during + authorization. + + + + + + + )} + /> + ( + + Scopes + + Scope is a string containing a space-separated list of scope values (as + described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use + when requesting access tokens. + + + + + + + )} + /> + {redirectUris.map((uri, index) => ( +
+ + Redirect + URI {index + 1} + +
+ handleInputChange(index, event)} + /> + {redirectUris.length > 1 && ( + + )} +
+
+ {form.formState.errors?.redirect_uris && form.formState.errors.redirect_uris[index] && ( + {form.formState.errors.redirect_uris[index].message} + )} +
+
+ ))} + + +
+
+ + + + + Consent Screen + + + + ( + +
+ + Skip consent + + + Whether or not the consent screen is skipped for this client + +
+ + + +
+ )} + /> + { + !form.getValues('skip') && ( + <> + ( + + Logo URI + + A URL string referencing the client's logo. + + + + + + + )} + /> + + ( + + Policy URI + + A URL string pointing to a human-readable privacy policy + document + for the client that describes how the deployment organization + collects, uses, retains, and discloses personal data. + + + + + + + )} + /> + + ( + + Terms URI + + A URL string pointing to a human-readable terms of service + document for the client that describes a contractual + relationship between the end-user and the client that the + end-user accepts when authorizing the client. + + + + + + + )} + /> + + ( + + Owner + + Owner is a string identifying the owner of the OAuth 2.0 Client. + + + + + + + )} + /> + + ) + } +
+
+ + + + + Supported OAuth2 flows + + + Configure allowed grant types and response types for this OAuth2 Client. + + + + ( + + Grant types + + {/* TODO: add multiselect component */} + + + + + )} + /> + ( + + Response types + + {/* TODO: add multiselect component */} + + + + + )} + /> + + Access token type + + + + + + + + + + + + Client authentication mechanism + + + Set the client authentication method for the token endpoint. By default the client + credentials must be sent in the body of an HTTP POST. This option can also specify for + sending the credentials encoded in the HTTP Authorization header or by using JSON Web + Tokens. Specify none for public clients (native apps, mobile apps) which can not have + secrets. + + + + ( + + Authentication method + + + + )} + /> + + + + + + + OpenID Connect logout + + + Get more information about using front and backchannels here  + . + + + + ( + +
+ + Frontchannel Logout Session Required + + + Boolean value specifying whether the Relay Party (RP) requires that + issuer and session ID query parameters be included to identify the RP + session with the OpenID provider (OP) when the Frontchannel Logout URI + is used. The default value is false. + +
+ + + +
+ )} + /> + ( + + Frontchannel Logout URI + + URL that will cause the Relying Party (RP) to log itself out when rendered + in an iframe by the OpenID provider (OP). An issuer query parameter and a + session ID query parameter MAY be included by the OpenID provider (OP) to + enable the Relying Party (RP) to validate the request and to determine which + of the potentially multiple sessions is to be logged out; if either is + included, both MUST be. + + + + + + + )} + /> + ( + +
+ + Backchannel Logout Session Required + + + Boolean value specifying whether the Relying Party (RP) requires that a + session ID Claim be included in the Logout Token to identify the Relying + Party session with the OpenID provider (OP) when the Backchannel Logout + URI is used. If omitted, the default value is false. + +
+ + + +
+ )} + /> + ( + + Backchannel Logout URI + + URL that will cause the Relying Party (RP) to log itself out when rendered + in an iframe by the OpenID provider (OP). An issuer query parameter and a + session ID query parameter MAY be included by the OpenID provider (OP) to + enable the Relying Party (RP) to validate the request and to determine which + of the potentially multiple sessions is to be logged out; if either is + included, both MUST be. + + + + + + + )} + /> + ( + +
+ + Skip logout consent + + + Boolean value specifying whether the additional logout consent screen + should be skipped. + +
+ + + +
+ )} + /> + {postLogoutRedirectUris.map((uri, index) => ( +
+ + + Post Logout Redirect URI {index + 1} + +
+ handlePostLogoutInputChange(index, event)} + /> + {postLogoutRedirectUris.length > 1 && ( + + )} +
+
+ {form.formState.errors?.post_logout_redirect_uris && form.formState.errors.post_logout_redirect_uris[index] && ( + {form.formState.errors.post_logout_redirect_uris[index].message} + )} +
+
+ ))} + + +
+
+ +
+ + +
+
+ + + ); +} diff --git a/dashboard/src/components/ui/card.tsx b/dashboard/src/components/ui/card.tsx index 3c69472..22ccc98 100644 --- a/dashboard/src/components/ui/card.tsx +++ b/dashboard/src/components/ui/card.tsx @@ -23,7 +23,7 @@ const CardHeader = React.forwardRef< >(({ className, ...props }, ref) => (
)); @@ -60,7 +60,7 @@ const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); CardContent.displayName = 'CardContent'; @@ -70,7 +70,7 @@ const CardFooter = React.forwardRef< >(({ className, ...props }, ref) => (
)); diff --git a/dashboard/src/components/ui/command.tsx b/dashboard/src/components/ui/command.tsx new file mode 100644 index 0000000..f485776 --- /dev/null +++ b/dashboard/src/components/ui/command.tsx @@ -0,0 +1,154 @@ +'use client'; + +import * as React from 'react'; +import { type DialogProps } from '@radix-ui/react-dialog'; +import { Command as CommandPrimitive } from 'cmdk'; +import { Search } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props + }: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = 'CommandShortcut'; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/dashboard/src/components/ui/dialog.tsx b/dashboard/src/components/ui/dialog.tsx new file mode 100644 index 0000000..c7b4160 --- /dev/null +++ b/dashboard/src/components/ui/dialog.tsx @@ -0,0 +1,123 @@ +'use client'; + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props + }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props + }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/dashboard/src/components/ui/multi-select.tsx b/dashboard/src/components/ui/multi-select.tsx new file mode 100644 index 0000000..266eea7 --- /dev/null +++ b/dashboard/src/components/ui/multi-select.tsx @@ -0,0 +1,129 @@ +'use client'; + +import * as React from 'react'; +import { X } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'; +import { Command as CommandPrimitive } from 'cmdk'; + +type MultiSelectItem = Record<'value' | 'label', string>; + +interface MultiSelectProps { + items: MultiSelectItem[]; + placeholder?: string; +} + +// Credits: https://github.com/mxkaske/mxkaske.dev/blob/main/components/craft/fancy-multi-select.tsx +export function MultiSelect({ items, placeholder }: MultiSelectProps) { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [selected, setSelected] = React.useState([]); + const [inputValue, setInputValue] = React.useState(''); + + const handleUnselect = React.useCallback((item: MultiSelectItem) => { + setSelected((prev) => prev.filter((s) => s.value !== item.value)); + }, []); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === 'Delete' || e.key === 'Backspace') { + if (input.value === '') { + setSelected((prev) => { + const newSelected = [...prev]; + newSelected.pop(); + return newSelected; + }); + } + } + // This is not a default behaviour of the field + if (e.key === 'Escape') { + input.blur(); + } + } + }, + [], + ); + + const selectables = items.filter( + (item) => !selected.includes(item), + ); + + console.log(selectables, selected, inputValue); + + return ( + +
+
+ {selected.map((item) => { + return ( + + {item.label} + + + ); + })} + {/* Avoid having the "Search" Icon */} + setOpen(false)} + onFocus={() => setOpen(true)} + placeholder={placeholder ?? 'Select an item...'} + className="ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground" + /> +
+
+
+ + {open && selectables.length > 0 ? ( +
+ + {selectables.map((item) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value) => { + setInputValue(''); + setSelected((prev) => [...prev, item]); + }} + className={'cursor-pointer'} + > + {item.label} + + ); + })} + +
+ ) : null} +
+
+
+ ); +} \ No newline at end of file diff --git a/dashboard/src/components/ui/select.tsx b/dashboard/src/components/ui/select.tsx new file mode 100644 index 0000000..ddd77ac --- /dev/null +++ b/dashboard/src/components/ui/select.tsx @@ -0,0 +1,160 @@ +'use client'; + +import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/dashboard/src/components/ui/switch.tsx b/dashboard/src/components/ui/switch.tsx new file mode 100644 index 0000000..4c01734 --- /dev/null +++ b/dashboard/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +'use client'; + +import * as React from 'react'; +import * as SwitchPrimitives from '@radix-ui/react-switch'; + +import { cn } from '@/lib/utils'; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/dashboard/src/lib/action/client.ts b/dashboard/src/lib/action/client.ts new file mode 100644 index 0000000..fb5156b --- /dev/null +++ b/dashboard/src/lib/action/client.ts @@ -0,0 +1,23 @@ +'use server'; + +import { clientFormSchema } from '@/lib/forms/client-form'; +import { z } from 'zod'; +import { getOAuth2Api } from '@/ory/sdk/server'; +import { checkPermission, requireSession } from '@/lib/action/authentication'; +import { permission, relation } from '@/lib/permission'; + +export async function createClient( + formData: z.infer, +) { + + const session = await requireSession(); + const allowed = await checkPermission(permission.client.it, relation.create, session.identity!.id); + if (!allowed) { + throw Error('Unauthorised'); + } + + console.log(session.identity?.traits.email, 'posted form', formData); + + const oauthApi = await getOAuth2Api(); + return await oauthApi.createOAuth2Client({ oAuth2Client: formData }); +} diff --git a/dashboard/src/lib/forms/client-form.ts b/dashboard/src/lib/forms/client-form.ts new file mode 100644 index 0000000..ea3814e --- /dev/null +++ b/dashboard/src/lib/forms/client-form.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const clientFormSchema = z.object({ + access_token_strategy: z.string().default('opaque').readonly(), + client_name: z.string().min(1, 'Client name is required'), + scope: z.string(), + redirect_uris: z.array(z.string().url({ message: 'Invalid URL' })).min(1, { message: 'At least one redirect URI is required' }), + skip: z.boolean(), + logo_uri: z.string().url(), + tos_uri: z.string().url(), + policy_uri: z.string().url(), + owner: z.string().min(1, 'Owner is required'), + grant_types: z.array(z.string()), + response_types: z.array(z.string()), + token_endpoint_auth_method: z.string(), + backchannel_logout_session_required: z.boolean().default(false), + backchannel_logout_uri: z.string().url(), + frontchannel_logout_session_required: z.boolean().default(false), + frontchannel_logout_uri: z.string().url(), + skip_logout_consent: z.boolean().default(false), + post_logout_redirect_uris: z.array(z.string().url({ message: 'Invalid URL' })).min(1, { message: 'At least one redirect URI is required' }), +}); diff --git a/dashboard/src/lib/permission.ts b/dashboard/src/lib/permission.ts index 9fc8ba0..38619ac 100644 --- a/dashboard/src/lib/permission.ts +++ b/dashboard/src/lib/permission.ts @@ -13,6 +13,9 @@ export const permission = { state: 'admin.user.state', trait: 'admin.user.trait', }, + client: { + it: 'admin.client', + }, }; export const relation = {