diff --git a/ui/admin/app/components/Typography.tsx b/ui/admin/app/components/Typography.tsx index 0fab5cf3..34a9613a 100644 --- a/ui/admin/app/components/Typography.tsx +++ b/ui/admin/app/components/Typography.tsx @@ -60,11 +60,7 @@ export function TypographyH4({ children, className }: TypographyProps) { } export function TypographyP({ children, className }: TypographyProps) { - return ( -

- {children} -

- ); + return

{children}

; } export function TypographyBlockquote({ children, className }: TypographyProps) { diff --git a/ui/admin/app/components/composed/ConfirmationDialog.tsx b/ui/admin/app/components/composed/ConfirmationDialog.tsx index 2e96f125..97dd3692 100644 --- a/ui/admin/app/components/composed/ConfirmationDialog.tsx +++ b/ui/admin/app/components/composed/ConfirmationDialog.tsx @@ -39,9 +39,11 @@ export function ConfirmationDialog({ - + + + diff --git a/ui/admin/app/components/composed/CopyText.tsx b/ui/admin/app/components/composed/CopyText.tsx new file mode 100644 index 00000000..f3d82976 --- /dev/null +++ b/ui/admin/app/components/composed/CopyText.tsx @@ -0,0 +1,79 @@ +import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { cn } from "~/lib/utils"; + +import { Button } from "~/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip"; + +export function CopyText({ + text, + displayText = text, + className, +}: { + text: string; + displayText?: string; + className?: string; +}) { + const [isCopied, setIsCopied] = useState(false); + + useEffect(() => { + if (!isCopied) return; + + const timeout = setTimeout(() => setIsCopied(false), 10000); + + return () => clearTimeout(timeout); + }, [isCopied]); + + return ( +
+ + + handleCopy(text)} + className="decoration-dotted underline-offset-4 underline text-ellipsis overflow-hidden text-nowrap" + > + {displayText} + + + + Copy: + {text} + + + + + +
+ ); + + async function handleCopy(text: string) { + try { + await navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + setIsCopied(true); + } catch (error) { + console.error("Failed to copy text: ", error); + toast.error("Failed to copy text"); + } + } +} diff --git a/ui/admin/app/components/oauth-apps/CreateOauthApp.tsx b/ui/admin/app/components/oauth-apps/CreateOauthApp.tsx index dccbec2c..8b06e9cc 100644 --- a/ui/admin/app/components/oauth-apps/CreateOauthApp.tsx +++ b/ui/admin/app/components/oauth-apps/CreateOauthApp.tsx @@ -1,113 +1,80 @@ import { DialogDescription } from "@radix-ui/react-dialog"; -import { PlusIcon } from "lucide-react"; -import { useState } from "react"; +import { SettingsIcon } from "lucide-react"; +import { toast } from "sonner"; import { mutate } from "swr"; +import { OAuthAppParams } from "~/lib/model/oauthApps"; +import { OAuthProvider } from "~/lib/model/oauthApps/oauth-helpers"; import { OauthAppService } from "~/lib/service/api/oauthAppService"; import { Button } from "~/components/ui/button"; import { - Command, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "~/components/ui/command"; -import { Dialog, DialogContent, DialogTitle } from "~/components/ui/dialog"; + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { ScrollArea } from "~/components/ui/scroll-area"; import { - Popover, - PopoverContent, - PopoverTrigger, -} from "~/components/ui/popover"; -import { useOAuthAppSpec } from "~/hooks/oauthApps/useOAuthAppSpec"; + useOAuthAppInfo, + useOAuthAppList, +} from "~/hooks/oauthApps/useOAuthApps"; import { useAsync } from "~/hooks/useAsync"; import { useDisclosure } from "~/hooks/useDisclosure"; import { OAuthAppForm } from "./OAuthAppForm"; +import { OAuthAppTypeIcon } from "./OAuthAppTypeIcon"; -export function CreateOauthApp() { - const selectModal = useDisclosure(); - const { data: spec } = useOAuthAppSpec(); +export function CreateOauthApp({ type }: { type: OAuthProvider }) { + const spec = useOAuthAppInfo(type); + const modal = useDisclosure(); - const [selectedAppKey, setSelectedAppKey] = useState(null); + const createApp = useAsync(async (data: OAuthAppParams) => { + await OauthAppService.createOauthApp({ + type, + refName: type, + ...data, + }); - const createApp = useAsync(OauthAppService.createOauthApp, { - onSuccess: () => { - mutate(OauthAppService.getOauthApps.key()); - setSelectedAppKey(null); - }, - }); + await mutate(useOAuthAppList.key()); - const selectedSpec = selectedAppKey ? spec.get(selectedAppKey) : null; + modal.onClose(); + toast.success(`${spec.displayName} OAuth app created`); + }); return ( - <> - - - - - - - - - - - - {Array.from(spec.entries()).map( - ([key, { displayName }]) => ( - { - setSelectedAppKey(key); - selectModal.onClose(); - }} - > - {displayName} - - ) - )} - - - - - + + + + - setSelectedAppKey(null)} + - - {selectedAppKey && selectedSpec && ( - <> - - Create {selectedSpec.displayName} OAuth App - + + + Configure {spec.displayName} OAuth App + - - Create a new OAuth app for{" "} - {selectedSpec.displayName} - + - - createApp.execute({ - type: selectedAppKey, - ...data, - }) - } - /> - - )} - - - + + + + + ); } diff --git a/ui/admin/app/components/oauth-apps/DeleteOAuthApp.tsx b/ui/admin/app/components/oauth-apps/DeleteOAuthApp.tsx index 0a0fbb34..a31889f5 100644 --- a/ui/admin/app/components/oauth-apps/DeleteOAuthApp.tsx +++ b/ui/admin/app/components/oauth-apps/DeleteOAuthApp.tsx @@ -1,4 +1,5 @@ import { TrashIcon } from "lucide-react"; +import { toast } from "sonner"; import { mutate } from "swr"; import { OauthAppService } from "~/lib/service/api/oauthAppService"; @@ -12,21 +13,31 @@ import { TooltipProvider, TooltipTrigger, } from "~/components/ui/tooltip"; +import { useOAuthAppList } from "~/hooks/oauthApps/useOAuthApps"; import { useAsync } from "~/hooks/useAsync"; -export function DeleteOAuthApp({ id }: { id: string }) { - const deleteOAuthApp = useAsync(OauthAppService.deleteOauthApp, { - onSuccess: () => mutate(OauthAppService.getOauthApps.key()), +export function DeleteOAuthApp({ + id, + disableTooltip, +}: { + id: string; + disableTooltip?: boolean; +}) { + const deleteOAuthApp = useAsync(async () => { + await OauthAppService.deleteOauthApp(id); + await mutate(useOAuthAppList.key()); + + toast.success("OAuth app deleted"); }); return (
- + deleteOAuthApp.execute(id)} + onConfirm={deleteOAuthApp.execute} confirmProps={{ variant: "destructive", children: "Delete", @@ -34,15 +45,16 @@ export function DeleteOAuthApp({ id }: { id: string }) { > @@ -52,4 +64,8 @@ export function DeleteOAuthApp({ id }: { id: string }) {
); + + function getIsOpen() { + if (disableTooltip) return false; + } } diff --git a/ui/admin/app/components/oauth-apps/EditOAuthApp.tsx b/ui/admin/app/components/oauth-apps/EditOAuthApp.tsx index d2cf3ec1..fbbb239d 100644 --- a/ui/admin/app/components/oauth-apps/EditOAuthApp.tsx +++ b/ui/admin/app/components/oauth-apps/EditOAuthApp.tsx @@ -1,7 +1,9 @@ -import { SquarePenIcon } from "lucide-react"; +import { GearIcon } from "@radix-ui/react-icons"; +import { toast } from "sonner"; import { mutate } from "swr"; -import { OAuthApp } from "~/lib/model/oauthApps"; +import { OAuthAppParams } from "~/lib/model/oauthApps"; +import { OAuthProvider } from "~/lib/model/oauthApps/oauth-helpers"; import { OauthAppService } from "~/lib/service/api/oauthAppService"; import { Button } from "~/components/ui/button"; @@ -12,55 +14,60 @@ import { DialogTitle, DialogTrigger, } from "~/components/ui/dialog"; -import { useOAuthAppSpec } from "~/hooks/oauthApps/useOAuthAppSpec"; +import { + useOAuthAppInfo, + useOAuthAppList, +} from "~/hooks/oauthApps/useOAuthApps"; import { useAsync } from "~/hooks/useAsync"; import { useDisclosure } from "~/hooks/useDisclosure"; import { OAuthAppForm } from "./OAuthAppForm"; +import { OAuthAppTypeIcon } from "./OAuthAppTypeIcon"; -type EditOAuthAppProps = { - oauthApp: OAuthApp; -}; +export function EditOAuthApp({ type }: { type: OAuthProvider }) { + const spec = useOAuthAppInfo(type); + const modal = useDisclosure(); -export function EditOAuthApp({ oauthApp }: EditOAuthAppProps) { - const updateApp = useAsync(OauthAppService.updateOauthApp, { - onSuccess: async () => { - await mutate(OauthAppService.getOauthApps.key()); - modal.onClose(); - }, - }); + const { customApp } = spec; - const modal = useDisclosure(); + const updateApp = useAsync(async (data: OAuthAppParams) => { + if (!customApp) return; - const { data: spec } = useOAuthAppSpec(); + await OauthAppService.updateOauthApp(customApp.id, { + type: customApp.type, + refName: customApp.refName, + ...data, + }); + await mutate(useOAuthAppList.key()); + modal.onClose(); + toast.success(`${spec.displayName} OAuth app updated`); + }); - const typeSpec = spec.get(oauthApp.type); - if (!typeSpec) return null; + if (!customApp) return null; return ( - - Edit OAuth App ({oauthApp.type}) + + Edit {spec.displayName}{" "} + OAuth Configuration + - updateApp.execute(oauthApp.id, { - type: oauthApp.type, - ...data, - }) - } + type={type} + onSubmit={updateApp.execute} + isLoading={updateApp.isLoading} /> diff --git a/ui/admin/app/components/oauth-apps/OAuthAppDetail.tsx b/ui/admin/app/components/oauth-apps/OAuthAppDetail.tsx new file mode 100644 index 00000000..ffc7b7fb --- /dev/null +++ b/ui/admin/app/components/oauth-apps/OAuthAppDetail.tsx @@ -0,0 +1,116 @@ +import { SettingsIcon } from "lucide-react"; + +import { OAuthApp } from "~/lib/model/oauthApps"; +import { + OAuthAppSpec, + OAuthProvider, +} from "~/lib/model/oauthApps/oauth-helpers"; +import { cn } from "~/lib/utils"; + +import { TypographyP } from "~/components/Typography"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { useOAuthAppInfo } from "~/hooks/oauthApps/useOAuthApps"; + +import { CreateOauthApp } from "./CreateOauthApp"; +import { DeleteOAuthApp } from "./DeleteOAuthApp"; +import { EditOAuthApp } from "./EditOAuthApp"; +import { OAuthAppTypeIcon } from "./OAuthAppTypeIcon"; + +export function OAuthAppDetail({ + type, + className, +}: { + type: OAuthProvider; + className?: string; +}) { + const spec = useOAuthAppInfo(type); + + if (!spec) { + console.error(`OAuth app ${type} not found`); + return null; + } + + return ( + + + + + + + + + + + + + {spec?.displayName} + + + + {spec?.customApp ? ( + + ) : ( + + )} + + + ); +} + +function EmptyContent({ spec }: { spec: OAuthAppSpec }) { + return ( +
+ + {spec.displayName} OAuth is automatically being handled by the + Acorn Gateway + + + + If you would like Otto to use your own custom {spec.displayName}{" "} + OAuth App, you can configure it by clicking the button below. + + + +
+ ); +} + +function Content({ app, spec }: { app: OAuthApp; spec: OAuthAppSpec }) { + return ( +
+ + You have a custom configuration for {spec.displayName} OAuth. + + + + When {spec.displayName} OAuth is used, Otto will use your custom + OAuth app. + + +
+ + Client ID + + {app.clientID} + + + Client Secret + + **************** +
+ + + +
+ ); +} diff --git a/ui/admin/app/components/oauth-apps/OAuthAppForm.tsx b/ui/admin/app/components/oauth-apps/OAuthAppForm.tsx index 52b43815..db567634 100644 --- a/ui/admin/app/components/oauth-apps/OAuthAppForm.tsx +++ b/ui/admin/app/components/oauth-apps/OAuthAppForm.tsx @@ -1,49 +1,46 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useMemo } from "react"; import { useForm } from "react-hook-form"; -import { z } from "zod"; +import Markdown from "react-markdown"; +import rehypeExternalLinks from "rehype-external-links"; -import { OAuthApp, OAuthAppInfo, OAuthAppParams } from "~/lib/model/oauthApps"; +import { OAuthAppParams } from "~/lib/model/oauthApps"; +import { + OAuthFormStep, + OAuthProvider, +} from "~/lib/model/oauthApps/oauth-helpers"; +import { cn } from "~/lib/utils"; +import { CopyText } from "~/components/composed/CopyText"; import { ControlledInput } from "~/components/form/controlledInputs"; +import { CustomMarkdownComponents } from "~/components/react-markdown"; +import { LoadingSpinner } from "~/components/ui/LoadingSpinner"; import { Button } from "~/components/ui/button"; import { Form } from "~/components/ui/form"; +import { useOAuthAppInfo } from "~/hooks/oauthApps/useOAuthApps"; type OAuthAppFormProps = { - appSpec: OAuthAppInfo; + type: OAuthProvider; onSubmit: (data: OAuthAppParams) => void; - oauthApp?: OAuthApp; + isLoading?: boolean; }; -export function OAuthAppForm({ - appSpec, - onSubmit, - oauthApp, -}: OAuthAppFormProps) { - const isEdit = !!oauthApp; +export function OAuthAppForm({ type, onSubmit, isLoading }: OAuthAppFormProps) { + const spec = useOAuthAppInfo(type); + + const isEdit = !!spec.customApp; const fields = useMemo(() => { - return Object.entries(appSpec.parameters).map(([key, label]) => ({ + return Object.entries(spec.schema.shape).map(([key]) => ({ key: key as keyof OAuthAppParams, - label, })); - }, [appSpec.parameters]); - - const schema = useMemo(() => { - return z.object( - fields.reduce( - (acc, { key }) => { - acc[key] = z.string(); - return acc; - }, - {} as Record - ) - ); - }, [fields]); + }, [spec.schema]); const defaultValues = useMemo(() => { + const app = spec.customApp; + return fields.reduce((acc, { key }) => { - acc[key] = oauthApp?.[key] ?? ""; + acc[key] = app?.[key] ?? ""; // if editing, use placeholder to show secret value exists // use a uuid to ensure it never collides with a real secret @@ -53,11 +50,11 @@ export function OAuthAppForm({ return acc; }, {} as OAuthAppParams); - }, [fields, oauthApp, isEdit]); + }, [fields, spec.customApp, isEdit]); const form = useForm({ defaultValues, - resolver: zodResolver(schema), + resolver: zodResolver(spec.schema), }); useEffect(() => { @@ -75,30 +72,57 @@ export function OAuthAppForm({ onSubmit(data); } }); + return (
- {fields.map(({ key, label }) => ( - - ))} + {spec.steps.map(renderStep)} - + ); + function renderStep(step: OAuthFormStep) { + switch (step.type) { + case "markdown": + return ( + + {step.text} + + ); + case "input": { + return ( + + ); + } + case "copy": { + return ; + } + } + } + function onBlurClientSecret() { if (!isEdit) return; diff --git a/ui/admin/app/components/oauth-apps/OAuthAppList.tsx b/ui/admin/app/components/oauth-apps/OAuthAppList.tsx index ffb07f2f..0302dd84 100644 --- a/ui/admin/app/components/oauth-apps/OAuthAppList.tsx +++ b/ui/admin/app/components/oauth-apps/OAuthAppList.tsx @@ -1,109 +1,27 @@ -import { ColumnDef, createColumnHelper } from "@tanstack/react-table"; -import { KeyIcon } from "lucide-react"; -import { useMemo } from "react"; -import useSWR from "swr"; +import { TypographyH3, TypographyP } from "~/components/Typography"; +import { useOAuthAppList } from "~/hooks/oauthApps/useOAuthApps"; -import { OAuthApp } from "~/lib/model/oauthApps"; -import { OauthAppService } from "~/lib/service/api/oauthAppService"; -import { cn } from "~/lib/utils"; - -import { DataTable } from "~/components/composed/DataTable"; -import { useOAuthAppSpec } from "~/hooks/oauthApps/useOAuthAppSpec"; - -import { DeleteOAuthApp } from "./DeleteOAuthApp"; -import { EditOAuthApp } from "./EditOAuthApp"; - -type Row = OAuthApp & { created?: string; isGateway?: boolean }; -const columnHelper = createColumnHelper(); +import { OAuthAppTile } from "./OAuthAppTile"; export function OAuthAppList() { - const { data: spec } = useOAuthAppSpec(); - - const { data: apps } = useSWR( - OauthAppService.getOauthApps.key(), - OauthAppService.getOauthApps, - { fallbackData: [] } - ); - - const rows = useMemo(() => { - const typesWithNoApps = Array.from(spec.entries()) - .map(([type, { displayName }]) => { - if (apps.some((app) => app.type === type)) return null; - - return { - type, - name: displayName + " (Acorn Gateway)", - id: type, - isGateway: true, - } as Row; - }) - .filter((x) => !!x); - - return apps.concat(typesWithNoApps); - }, [apps, spec]); + const apps = useOAuthAppList(); return ( - cn(row.isGateway && "opacity-60")} - classNames={{ - row: "!max-h-[200px] grow-0 height-[200px]", - cell: "!max-h-[200px] grow-0 height-[200px]", - }} - /> +
+
+ Pre-configured OAuth Apps + + These apps are pre-configured and ready to use. For the most + part, you should not need to configure any additional OAuth + apps. + +
+ +
+ {apps.map(({ type }) => ( + + ))} +
+
); - - function getColumns(): ColumnDef[] { - return [ - columnHelper.display({ - id: "icon", - cell: ({ row }) => { - const app = row.original; - const { icon } = spec.get(app.type) || {}; - return icon ? ( - {app.type} - ) : ( - - ); - }, - }), - columnHelper.accessor( - (app) => spec.get(app.type)?.displayName ?? app.type, - { - id: "type", - header: "Type", - } - ), - columnHelper.accessor((app) => app.name ?? app.id, { - id: "name", - header: "Name / Id", - }), - columnHelper.accessor( - (app) => - app.created ? new Date(app.created).toLocaleString() : "-", - { header: "Created" } - ), - columnHelper.display({ - id: "actions", - cell: ({ row }) => - !row.original.isGateway && ( -
- - -
- ), - }), - ]; - } } diff --git a/ui/admin/app/components/oauth-apps/OAuthAppTile.tsx b/ui/admin/app/components/oauth-apps/OAuthAppTile.tsx new file mode 100644 index 00000000..3c3e1d94 --- /dev/null +++ b/ui/admin/app/components/oauth-apps/OAuthAppTile.tsx @@ -0,0 +1,29 @@ +import { OAuthProvider } from "~/lib/model/oauthApps/oauth-helpers"; + +import { Card } from "~/components/ui/card"; +import { useOAuthAppInfo } from "~/hooks/oauthApps/useOAuthApps"; + +import { OAuthAppDetail } from "./OAuthAppDetail"; + +export function OAuthAppTile({ type }: { type: OAuthProvider }) { + const info = useOAuthAppInfo(type); + + if (!info) { + console.error(`OAuth app ${type} not found`); + return null; + } + + const { displayName } = info; + + return ( + + {displayName} + + + + ); +} diff --git a/ui/admin/app/components/oauth-apps/OAuthAppTypeIcon.tsx b/ui/admin/app/components/oauth-apps/OAuthAppTypeIcon.tsx new file mode 100644 index 00000000..75bbf259 --- /dev/null +++ b/ui/admin/app/components/oauth-apps/OAuthAppTypeIcon.tsx @@ -0,0 +1,21 @@ +import { KeyIcon } from "lucide-react"; +import { FaGithub } from "react-icons/fa"; + +import { OAuthProvider } from "~/lib/model/oauthApps/oauth-helpers"; +import { cn } from "~/lib/utils"; + +const IconMap = { + [OAuthProvider.GitHub]: FaGithub, +}; + +export function OAuthAppTypeIcon({ + type, + className, +}: { + type: OAuthProvider; + className?: string; +}) { + const Icon = IconMap[type] ?? KeyIcon; + + return ; +} diff --git a/ui/admin/app/components/ui/dialog.tsx b/ui/admin/app/components/ui/dialog.tsx index f8ab3b49..93485ca0 100644 --- a/ui/admin/app/components/ui/dialog.tsx +++ b/ui/admin/app/components/ui/dialog.tsx @@ -29,15 +29,21 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + classNames?: { + content?: string; + overlay?: string; + }; + } +>(({ className, children, classNames = {}, ...props }, ref) => ( - + diff --git a/ui/admin/app/components/ui/sheet.tsx b/ui/admin/app/components/ui/sheet.tsx index afef28b0..7d0798c5 100644 --- a/ui/admin/app/components/ui/sheet.tsx +++ b/ui/admin/app/components/ui/sheet.tsx @@ -47,12 +47,14 @@ const sheetVariants = cva( interface SheetContentProps extends React.ComponentPropsWithoutRef, - VariantProps {} + VariantProps { + hideClose?: boolean; +} const SheetContent = React.forwardRef< React.ElementRef, SheetContentProps ->(({ side = "right", className, children, ...props }, ref) => ( +>(({ side = "right", className, children, hideClose, ...props }, ref) => ( - - - Close - + {!hideClose && ( + + + Close + + )} + {children} diff --git a/ui/admin/app/components/ui/tooltip.tsx b/ui/admin/app/components/ui/tooltip.tsx index d2e209ef..c4b56af5 100644 --- a/ui/admin/app/components/ui/tooltip.tsx +++ b/ui/admin/app/components/ui/tooltip.tsx @@ -7,7 +7,7 @@ const TooltipProvider = TooltipPrimitive.Provider; // const Tooltip = TooltipPrimitive.Root; const Tooltip = ({ - delayDuration = 0, + delayDuration = 200, ...props }: React.ComponentProps) => { return ; diff --git a/ui/admin/app/hooks/oauthApps/useOAuthAppSpec.ts b/ui/admin/app/hooks/oauthApps/useOAuthAppSpec.ts deleted file mode 100644 index c5fcd7c0..00000000 --- a/ui/admin/app/hooks/oauthApps/useOAuthAppSpec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import useSWR from "swr"; - -import { OAuthAppInfo } from "~/lib/model/oauthApps"; -import { OauthAppService } from "~/lib/service/api/oauthAppService"; - -const fallbackData = new Map(); - -export function useOAuthAppSpec() { - return useSWR( - OauthAppService.getSupportedOauthAppTypes.key(), - OauthAppService.getSupportedOauthAppTypes, - { fallbackData } - ); -} diff --git a/ui/admin/app/hooks/oauthApps/useOAuthApps.ts b/ui/admin/app/hooks/oauthApps/useOAuthApps.ts new file mode 100644 index 00000000..badfb55c --- /dev/null +++ b/ui/admin/app/hooks/oauthApps/useOAuthApps.ts @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import useSWR from "swr"; + +import { combinedOAuthAppInfo } from "~/lib/model/oauthApps"; +import { OAuthProvider } from "~/lib/model/oauthApps/oauth-helpers"; +import { OauthAppService } from "~/lib/service/api/oauthAppService"; + +const key = () => ({ + ...OauthAppService.getOauthApps.key(), + modifier: "combinedList", +}); + +export function useOAuthAppList(config?: { revalidate?: boolean }) { + const { revalidate = true } = config ?? {}; + + const { data: apps } = useSWR( + key(), + async () => combinedOAuthAppInfo(await OauthAppService.getOauthApps()), + { fallbackData: [], revalidateOnMount: revalidate } + ); + + return apps; +} +useOAuthAppList.key = key; + +export function useOAuthAppInfo(type: OAuthProvider) { + const list = useOAuthAppList({ revalidate: false }); + + const app = useMemo( + () => list.find((app) => app.type === type), + [list, type] + ); + + if (!app) { + throw new Error(`OAuth app ${type} not found`); + } + + return app; +} diff --git a/ui/admin/app/lib/model/oauthApps.ts b/ui/admin/app/lib/model/oauthApps.ts deleted file mode 100644 index f5c04f53..00000000 --- a/ui/admin/app/lib/model/oauthApps.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { EntityMeta } from "./primitives"; - -export type OAuthAppParams = { - refName?: string; - name?: string; - - clientID: string; - clientSecret?: string; - // These fields are only needed for custom OAuth apps. - authURL?: string; - tokenURL?: string; - // This field is only needed for Microsoft 365 OAuth apps. - tenantID?: string; - // This field is only needed for HubSpot OAuth apps. - appID?: string; - // This field is optional for HubSpot OAuth apps. - optionalScope?: string; - // This field is required, it correlates to the integration name in the gptscript oauth cred tool - integration?: string; -}; - -export type OAuthAppBase = OAuthAppParams & { - type: string; -}; - -export type OAuthApp = EntityMeta & OAuthAppBase; - -export type OAuthAppInfo = { - displayName: string; - icon?: string; - parameters: Record; -}; - -export type OAuthAppSpec = Record; diff --git a/ui/admin/app/lib/model/oauthApps/github.ts b/ui/admin/app/lib/model/oauthApps/github.ts new file mode 100644 index 00000000..273261b7 --- /dev/null +++ b/ui/admin/app/lib/model/oauthApps/github.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +import { assetUrl } from "~/lib/utils"; + +import { OAuthAppSpec, OAuthFormStep, getOAuthLinks } from "./oauth-helpers"; + +const schema = z.object({ + clientID: z.string(), + clientSecret: z.string(), +}); + +const steps: OAuthFormStep[] = [ + { + type: "markdown", + text: "### Step 1: Create a new GitHub OAuth App\n", + }, + { + type: "markdown", + text: + "#### If you haven't already, create a new GitHub OAuth App\n" + + "1. Navigate to [GitHub's Developer Settings](https://github.com/settings/developers) and select `New OAuth App`.\n" + + "2. The form will prompt you for an `Authorization callback Url` Make sure to use the link below: \n\n", + }, + { + type: "markdown", + text: + "#### If you already have a github OAuth app created\n" + + "1. you can edit it by going to [Github's Developer Settings](https://github.com/settings/developers), and selecting `Edit` on your OAuth App\n" + + "2. Near the bottom is the `Authorization callback URL` field. Make sure it matches the link below: \n\n", + }, + { + type: "copy", + text: getOAuthLinks("github").redirectURL, + }, + { + type: "markdown", + text: + "### Step 2: Register OAuth App in Otto\n" + + "Once you've created your OAuth App in GitHub, copy the client ID and client secret into this form", + }, + { type: "input", input: "clientID", label: "Client ID" }, + { type: "input", input: "clientSecret", label: "Client Secret" }, +]; + +export const GitHubOAuthApp = { + schema, + refName: "github", + type: "github", + displayName: "GitHub", + logo: assetUrl("/assets/github_logo.svg"), + steps, +} satisfies OAuthAppSpec; diff --git a/ui/admin/app/lib/model/oauthApps/index.ts b/ui/admin/app/lib/model/oauthApps/index.ts new file mode 100644 index 00000000..2e29cd30 --- /dev/null +++ b/ui/admin/app/lib/model/oauthApps/index.ts @@ -0,0 +1,46 @@ +import { EntityMeta } from "~/lib/model/primitives"; + +import { GitHubOAuthApp } from "./github"; +import { OAuthAppSpec, OAuthProvider } from "./oauth-helpers"; + +export const OAuthAppSpecMap = { + [OAuthProvider.GitHub]: GitHubOAuthApp, +} as const; + +export type OAuthAppDetail = OAuthAppSpec & { + customApp?: OAuthApp; +}; + +export const combinedOAuthAppInfo = (apps: OAuthApp[]) => { + return Object.entries(OAuthAppSpecMap).map(([type, defaultSpec]) => { + const customApp = apps.find((app) => app.type === type); + + return { ...defaultSpec, customApp } as OAuthAppDetail; + }); +}; + +export type OAuthAppParams = { + clientID: string; + clientSecret?: string; + // These fields are only needed for custom OAuth apps. + authURL?: string; + tokenURL?: string; + // This field is only needed for Microsoft 365 OAuth apps. + tenantID?: string; + // This field is only needed for HubSpot OAuth apps. + appID?: string; + // This field is optional for HubSpot OAuth apps. + optionalScope?: string; + // This field is required, it correlates to the integration name in the gptscript oauth cred tool + integration?: string; +}; + +export type OAuthAppBase = OAuthAppParams & { + type: OAuthProvider; + refName: string; +}; + +export type OAuthApp = EntityMeta & + OAuthAppBase & { + refNameAssigned?: boolean; + }; diff --git a/ui/admin/app/lib/model/oauthApps/oauth-helpers.ts b/ui/admin/app/lib/model/oauthApps/oauth-helpers.ts new file mode 100644 index 00000000..143f0bcb --- /dev/null +++ b/ui/admin/app/lib/model/oauthApps/oauth-helpers.ts @@ -0,0 +1,30 @@ +import { ZodObject, ZodType } from "zod"; + +import { apiBaseUrl } from "~/lib/routers/apiRoutes"; + +export const OAuthProvider = { + GitHub: "github", +} as const; +export type OAuthProvider = (typeof OAuthProvider)[keyof typeof OAuthProvider]; + +export type OAuthFormStep> = + | { type: "markdown"; text: string; copy?: string } + | { type: "input"; input: keyof T; label: string } + | { type: "copy"; text: string }; + +export type OAuthAppSpec = { + schema: ZodObject>; + displayName: string; + refName: string; + type: OAuthProvider; + logo: string; + steps: OAuthFormStep[]; +}; + +export function getOAuthLinks(type: OAuthProvider) { + return { + authorizeURL: `${apiBaseUrl}/app/oauth/authorize/${type}`, + redirectURL: `${apiBaseUrl}/app/oauth/callback/${type}`, + refreshURL: `${apiBaseUrl}/app/oauth/refresh/${type}`, + }; +} diff --git a/ui/admin/app/lib/service/api/oauthAppService.ts b/ui/admin/app/lib/service/api/oauthAppService.ts index ef2d43a2..db54cf3b 100644 --- a/ui/admin/app/lib/service/api/oauthAppService.ts +++ b/ui/admin/app/lib/service/api/oauthAppService.ts @@ -1,4 +1,4 @@ -import { OAuthApp, OAuthAppBase, OAuthAppSpec } from "~/lib/model/oauthApps"; +import { OAuthApp, OAuthAppBase } from "~/lib/model/oauthApps"; import { ApiRoutes } from "~/lib/routers/apiRoutes"; import { request } from "./primitives"; @@ -58,17 +58,6 @@ const deleteOauthApp = async (id: string) => { }); }; -const getSupportedOauthAppTypes = async () => { - const res = await request({ - url: ApiRoutes.oauthApps.supportedOauthAppTypes().url, - errorMessage: "Failed to get supported OAuth app types", - }); - - return new Map(Object.entries(res.data)); -}; -getSupportedOauthAppTypes.key = () => - ({ url: ApiRoutes.oauthApps.supportedOauthAppTypes().path }) as const; - const getSupportedAuthTypes = async () => { const res = await request({ url: ApiRoutes.oauthApps.supportedAuthTypes().url, @@ -86,6 +75,5 @@ export const OauthAppService = { createOauthApp, updateOauthApp, deleteOauthApp, - getSupportedOauthAppTypes, getSupportedAuthTypes, }; diff --git a/ui/admin/app/routes/_auth.oauth-apps.tsx b/ui/admin/app/routes/_auth.oauth-apps.tsx index 103ca7cb..f9a03daa 100644 --- a/ui/admin/app/routes/_auth.oauth-apps.tsx +++ b/ui/admin/app/routes/_auth.oauth-apps.tsx @@ -1,35 +1,21 @@ -import { useLoaderData } from "@remix-run/react"; import { preload } from "swr"; import { OauthAppService } from "~/lib/service/api/oauthAppService"; -import { CreateOauthApp } from "~/components/oauth-apps/CreateOauthApp"; import { OAuthAppList } from "~/components/oauth-apps/OAuthAppList"; export async function clientLoader() { - await Promise.all([ - preload( - OauthAppService.getSupportedOauthAppTypes.key(), - OauthAppService.getSupportedOauthAppTypes - ), - preload( - OauthAppService.getOauthApps.key(), - OauthAppService.getOauthApps - ), - ]); + await preload( + OauthAppService.getOauthApps.key(), + OauthAppService.getOauthApps + ); return null; } export default function OauthApps() { - useLoaderData(); - return (
-
- -
-
); diff --git a/ui/admin/public/assets/github_logo.svg b/ui/admin/public/assets/github_logo.svg new file mode 100644 index 00000000..f75f0fe2 --- /dev/null +++ b/ui/admin/public/assets/github_logo.svg @@ -0,0 +1,33 @@ + + + + + + + + +