From 071234d31dc871937c62f3cb38a1aaf93975f5e9 Mon Sep 17 00:00:00 2001 From: tylerslaton Date: Wed, 16 Oct 2024 12:15:25 -0400 Subject: [PATCH] feat: add auth support This PR adds in support for authentication to the admin side of Otto. This includes a sign-in page, user model, me routes, and a AuthContext for rendering the authenticated user state. This also includes a change to the API to change how the default routing was being handled. This was written by donnie@acorn.io. Signed-off-by: tylerslaton Co-authored-by: Donnie Adams --- pkg/proxy/proxy.go | 6 +- ui/admin/app/components/auth/AuthContext.tsx | 34 ++++++++++ ui/admin/app/components/header/HeaderNav.tsx | 5 +- ui/admin/app/components/signin/SignIn.tsx | 47 ++++++++++++++ ui/admin/app/components/user/UserMenu.tsx | 65 ++++++++++++++++++++ ui/admin/app/lib/model/users.ts | 29 +++++++++ ui/admin/app/lib/routers/apiRoutes.ts | 6 +- ui/admin/app/lib/service/api/userService.ts | 21 +++++++ ui/admin/app/root.tsx | 20 ++++-- ui/admin/app/routes/_auth.tsx | 17 ++++- ui/admin/app/routes/sign-in.tsx | 9 +++ ui/admin/package-lock.json | 10 +++ ui/admin/package.json | 1 + 13 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 ui/admin/app/components/auth/AuthContext.tsx create mode 100644 ui/admin/app/components/signin/SignIn.tsx create mode 100644 ui/admin/app/components/user/UserMenu.tsx create mode 100644 ui/admin/app/lib/model/users.ts create mode 100644 ui/admin/app/lib/service/api/userService.ts create mode 100644 ui/admin/app/routes/sign-in.tsx diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index ef6c99595..1e0e5051d 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -6,6 +6,8 @@ import ( "strings" "time" + "errors" + oauth2proxy "github.com/oauth2-proxy/oauth2-proxy/v7" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/validation" @@ -88,8 +90,8 @@ func (p *Proxy) Wrap(h http.Handler) http.Handler { return } - session, err := p.proxy.LoadCookiedSession(r) - if err != nil || session == nil { + _, err := p.proxy.LoadCookiedSession(r) + if strings.HasPrefix(r.URL.Path, "/oauth2") || err != nil && !errors.Is(err, http.ErrNoCookie) { p.proxy.ServeHTTP(w, r) } else { h.ServeHTTP(w, r) diff --git a/ui/admin/app/components/auth/AuthContext.tsx b/ui/admin/app/components/auth/AuthContext.tsx new file mode 100644 index 000000000..3e969e8f9 --- /dev/null +++ b/ui/admin/app/components/auth/AuthContext.tsx @@ -0,0 +1,34 @@ +import { ReactNode, createContext, useContext } from "react"; +import useSWR from "swr"; + +import { Role, User } from "~/lib/model/users"; +import { UserService } from "~/lib/service/api/userService"; + +interface AuthContextType { + me: User; + isLoading: boolean; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const { data: me, isLoading } = useSWR( + UserService.getMe.key(), + () => UserService.getMe(), + { fallbackData: { role: Role.Default } as User } + ); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within a AuthProvider"); + } + return context; +} diff --git a/ui/admin/app/components/header/HeaderNav.tsx b/ui/admin/app/components/header/HeaderNav.tsx index 32e883bdd..f62d123ae 100644 --- a/ui/admin/app/components/header/HeaderNav.tsx +++ b/ui/admin/app/components/header/HeaderNav.tsx @@ -8,12 +8,12 @@ import { ThreadsService } from "~/lib/service/api/threadsService"; import { QueryParamSchemas } from "~/lib/service/queryParamService"; import { cn, parseQueryParams } from "~/lib/utils"; +import { DarkModeToggle } from "~/components/DarkModeToggle"; import { TypographyH4, TypographySmall } from "~/components/Typography"; import { OttoLogo } from "~/components/branding/OttoLogo"; import { useLayout } from "~/components/layout/LayoutProvider"; import { Button } from "~/components/ui/button"; - -import { DarkModeToggle } from "../DarkModeToggle"; +import { UserMenu } from "~/components/user/UserMenu"; export function HeaderNav() { const { @@ -66,6 +66,7 @@ export function HeaderNav() {
+
diff --git a/ui/admin/app/components/signin/SignIn.tsx b/ui/admin/app/components/signin/SignIn.tsx new file mode 100644 index 000000000..9896d4a97 --- /dev/null +++ b/ui/admin/app/components/signin/SignIn.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { FaGoogle } from "react-icons/fa"; + +import { cn } from "~/lib/utils"; + +import { OttoLogo } from "~/components/branding/OttoLogo"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +interface SignInProps { + className?: string; +} + +const SignIn: React.FC = ({ className }) => { + return ( + + + + + + + Please sign in using the button below. + + + + + + + ); +}; + +export default SignIn; diff --git a/ui/admin/app/components/user/UserMenu.tsx b/ui/admin/app/components/user/UserMenu.tsx new file mode 100644 index 000000000..e5f38af2b --- /dev/null +++ b/ui/admin/app/components/user/UserMenu.tsx @@ -0,0 +1,65 @@ +import { User } from "lucide-react"; +import React from "react"; + +import { roleToString } from "~/lib/model/users"; +import { cn } from "~/lib/utils"; + +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; + +import { useAuth } from "../auth/AuthContext"; +import { Button } from "../ui/button"; + +interface UserMenuProps { + className?: string; +} + +export const UserMenu: React.FC = ({ className }) => { + const { me } = useAuth(); + + if (me.username === "nobody") { + return null; + } + + return ( + + +
+ + + + + + +
+

+ {me?.email} +

+

+ {roleToString(me?.role)} +

+
+
+
+ + + +
+ ); +}; diff --git a/ui/admin/app/lib/model/users.ts b/ui/admin/app/lib/model/users.ts new file mode 100644 index 000000000..a89cdb954 --- /dev/null +++ b/ui/admin/app/lib/model/users.ts @@ -0,0 +1,29 @@ +export type User = { + id: number; + createdAt: Date; + username: string; + email: string; + role: Role; +}; + +export const Role = { + Admin: 1, + Default: 2, +} as const; +export type Role = (typeof Role)[keyof typeof Role]; + +export function roleToString(role: Role): string { + return ( + Object.keys(Role).find( + (key) => Role[key as keyof typeof Role] === role + ) || "Unknown" + ); +} + +export function stringToRole(roleStr: string): Role { + const role = Role[roleStr as keyof typeof Role]; + if (role === undefined) { + throw new Error(`Invalid role string: ${roleStr}`); + } + return role; +} diff --git a/ui/admin/app/lib/routers/apiRoutes.ts b/ui/admin/app/lib/routers/apiRoutes.ts index 9d49e0962..66d939f2b 100644 --- a/ui/admin/app/lib/routers/apiRoutes.ts +++ b/ui/admin/app/lib/routers/apiRoutes.ts @@ -11,7 +11,10 @@ const buildUrl = (path: string, params?: object) => { ? queryString.stringify(params, { skipNull: true }) : ""; - if (process.env.NODE_ENV === "production" || import.meta.env.VITE_API_IN_BROWSER === "true") { + if ( + process.env.NODE_ENV === "production" || + import.meta.env.VITE_API_IN_BROWSER === "true" + ) { return { url: prodBaseUrl + path + (query ? "?" + query : ""), path, @@ -91,6 +94,7 @@ export const ApiRoutes = { getById: (toolReferenceId: string) => buildUrl(`/toolreferences/${toolReferenceId}`), }, + me: () => buildUrl("/me"), invoke: (id: string, threadId?: Nullish) => { return threadId ? buildUrl(`/invoke/${id}/threads/${threadId}`) diff --git a/ui/admin/app/lib/service/api/userService.ts b/ui/admin/app/lib/service/api/userService.ts new file mode 100644 index 000000000..26a185918 --- /dev/null +++ b/ui/admin/app/lib/service/api/userService.ts @@ -0,0 +1,21 @@ +import { User } from "~/lib/model/users"; +import { ApiRoutes, revalidateWhere } from "~/lib/routers/apiRoutes"; +import { request } from "~/lib/service/api/primitives"; + +async function getMe() { + const res = await request({ + url: ApiRoutes.me().url, + errorMessage: "Failed to fetch agents", + }); + + return res.data; +} +getMe.key = () => ({ url: ApiRoutes.me().path }) as const; + +const revalidateMe = () => + revalidateWhere((url) => url.includes(ApiRoutes.me().path)); + +export const UserService = { + getMe, + revalidateMe, +}; diff --git a/ui/admin/app/root.tsx b/ui/admin/app/root.tsx index f346fabe5..48e2b5773 100644 --- a/ui/admin/app/root.tsx +++ b/ui/admin/app/root.tsx @@ -7,10 +7,12 @@ import { ScrollRestoration, } from "@remix-run/react"; +import { AuthProvider } from "~/components/auth/AuthContext"; import { LayoutProvider } from "~/components/layout/LayoutProvider"; import { ThemeProvider } from "~/components/theme"; import { Toaster } from "~/components/ui/sonner"; +import { LoadingSpinner } from "./components/ui/LoadingSpinner"; import "./tailwind.css"; export const links: LinksFunction = () => [ @@ -50,14 +52,20 @@ export function Layout({ children }: { children: React.ReactNode }) { export default function App() { return ( - - - - - + + + + + + + ); } export function HydrateFallback() { - return

Loading...

; + return ( +
+ +
+ ); } diff --git a/ui/admin/app/routes/_auth.tsx b/ui/admin/app/routes/_auth.tsx index 34f902374..7f788ec64 100644 --- a/ui/admin/app/routes/_auth.tsx +++ b/ui/admin/app/routes/_auth.tsx @@ -1,8 +1,23 @@ -import { Outlet } from "@remix-run/react"; +import { Outlet, redirect } from "@remix-run/react"; +import { isAxiosError } from "axios"; +import { $path } from "remix-routes"; + +import { UserService } from "~/lib/service/api/userService"; import { HeaderNav } from "~/components/header/HeaderNav"; import { Sidebar } from "~/components/sidebar"; +export const clientLoader = async () => { + try { + await UserService.getMe(); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 403) { + throw redirect($path("/sign-in")); + } + } + return null; +}; + export default function AuthLayout() { return (
diff --git a/ui/admin/app/routes/sign-in.tsx b/ui/admin/app/routes/sign-in.tsx new file mode 100644 index 000000000..2c0c8c032 --- /dev/null +++ b/ui/admin/app/routes/sign-in.tsx @@ -0,0 +1,9 @@ +import SignIn from "~/components/signin/SignIn"; + +export default function SignInPage() { + return ( +
+ +
+ ); +} diff --git a/ui/admin/package-lock.json b/ui/admin/package-lock.json index 5c8c8a5a0..b10a195ea 100644 --- a/ui/admin/package-lock.json +++ b/ui/admin/package-lock.json @@ -38,6 +38,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.53.0", + "react-icons": "^5.3.0", "react-json-tree": "^0.19.0", "react-markdown": "^9.0.1", "react-resizable-panels": "^2.1.3", @@ -17124,6 +17125,15 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-icons": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz", + "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/ui/admin/package.json b/ui/admin/package.json index ac04bf81b..e640519f8 100644 --- a/ui/admin/package.json +++ b/ui/admin/package.json @@ -43,6 +43,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.53.0", + "react-icons": "^5.3.0", "react-json-tree": "^0.19.0", "react-markdown": "^9.0.1", "react-resizable-panels": "^2.1.3",