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({
Cancel
-
- {confirmProps?.children ?? "Confirm"}
-
+
+
+ {confirmProps?.children ?? "Confirm"}
+
+
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}
+
+
+
+
+ handleCopy(text)}
+ className="aspect-square"
+ variant="secondary"
+ type="button"
+ >
+ {isCopied ? (
+
+ ) : (
+
+ )}
+
+
+ );
+
+ 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 (
- <>
-
-
-
-
- New OAuth App
-
-
-
-
-
-
-
-
-
- {Array.from(spec.entries()).map(
- ([key, { displayName }]) => (
- {
- setSelectedAppKey(key);
- selectModal.onClose();
- }}
- >
- {displayName}
-
- )
- )}
-
-
-
-
-
+
+
+
+
+ Configure {spec.displayName} OAuth App
+
+
- setSelectedAppKey(null)}
+
-
- {selectedAppKey && selectedSpec && (
- <>
-
- Create {selectedSpec.displayName} OAuth App
-
+
+
+ Configure {spec.displayName} OAuth App
+
-
- Create a new OAuth app for{" "}
- {selectedSpec.displayName}
-
+
+ Create a new OAuth app for {spec.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 }) {
>
{deleteOAuthApp.isLoading ? (
-
+
) : (
-
+
)}
+ Delete OAuth App
@@ -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 Configuration
- Edit OAuth App ({oauthApp.type})
+
+ Edit {spec.displayName}{" "}
+ OAuth Configuration
+
Update the OAuth app settings.
- 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 (
+
+
+
+
+
+
+
+ OAuth App Details
+
+
+
+
+
+
+ {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 (
);
+ 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 ? (
-
- ) : (
-
- );
- },
- }),
- 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 (
+
+
+
+
+
+ );
+}
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 @@
+
+
+
+
+
+
+
+
+