Skip to content

Commit

Permalink
feat: add auth support
Browse files Browse the repository at this point in the history
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 [email protected].

Signed-off-by: tylerslaton <[email protected]>
Co-authored-by: Donnie Adams <[email protected]>
  • Loading branch information
tylerslaton and thedadams committed Oct 16, 2024
1 parent faefe47 commit 071234d
Show file tree
Hide file tree
Showing 13 changed files with 258 additions and 12 deletions.
6 changes: 4 additions & 2 deletions pkg/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions ui/admin/app/components/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -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<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
const { data: me, isLoading } = useSWR(
UserService.getMe.key(),
() => UserService.getMe(),
{ fallbackData: { role: Role.Default } as User }
);

return (
<AuthContext.Provider value={{ me, isLoading }}>
{children}
</AuthContext.Provider>
);
}

export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within a AuthProvider");
}
return context;
}
5 changes: 3 additions & 2 deletions ui/admin/app/components/header/HeaderNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -66,6 +66,7 @@ export function HeaderNav() {
</div>

<div className="flex items-center justify-center p-4 mr-4">
<UserMenu className="pr-4 border-r mr-4" />
<DarkModeToggle />
</div>
</div>
Expand Down
47 changes: 47 additions & 0 deletions ui/admin/app/components/signin/SignIn.tsx
Original file line number Diff line number Diff line change
@@ -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<SignInProps> = ({ className }) => {
return (
<Card className={cn("flex flex-col justify-between", className)}>
<CardHeader>
<CardTitle className="flex items-center justify-center">
<OttoLogo />
</CardTitle>
<CardDescription className="text-center w-3/4 mx-auto pt-4">
Please sign in using the button below.
</CardDescription>
</CardHeader>
<CardFooter className="border-t pt-4">
<Button
variant="destructive"
className="w-full"
onClick={() => {
window.location.href = "/oauth2/start?rd=/admin/";
}}
>
<FaGoogle className="mr-2" />
Sign In with Google
</Button>
</CardFooter>
</Card>
);
};

export default SignIn;
65 changes: 65 additions & 0 deletions ui/admin/app/components/user/UserMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<UserMenuProps> = ({ className }) => {
const { me } = useAuth();

if (me.username === "nobody") {
return null;
}

return (
<Popover>
<PopoverTrigger asChild>
<div
className={cn(
"flex items-center cursor-pointer",
className
)}
>
<Avatar className="mr-4">
<AvatarImage />
<AvatarFallback>
<User className="w-5 h-5" />
</AvatarFallback>
</Avatar>
<div className="truncate max-w-full">
<p className="text-sm font-medium truncate">
{me?.email}
</p>
<p className="text-muted-foreground text-left text-xs truncate">
{roleToString(me?.role)}
</p>
</div>
</div>
</PopoverTrigger>
<PopoverContent className="w-auto" side="bottom">
<Button
variant="destructive"
onClick={() => {
window.location.href = "/oauth2/sign_out?rd=/admin/";
}}
>
Sign Out
</Button>
</PopoverContent>
</Popover>
);
};
29 changes: 29 additions & 0 deletions ui/admin/app/lib/model/users.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 5 additions & 1 deletion ui/admin/app/lib/routers/apiRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -91,6 +94,7 @@ export const ApiRoutes = {
getById: (toolReferenceId: string) =>
buildUrl(`/toolreferences/${toolReferenceId}`),
},
me: () => buildUrl("/me"),
invoke: (id: string, threadId?: Nullish<string>) => {
return threadId
? buildUrl(`/invoke/${id}/threads/${threadId}`)
Expand Down
21 changes: 21 additions & 0 deletions ui/admin/app/lib/service/api/userService.ts
Original file line number Diff line number Diff line change
@@ -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<User>({
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,
};
20 changes: 14 additions & 6 deletions ui/admin/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => [
Expand Down Expand Up @@ -50,14 +52,20 @@ export function Layout({ children }: { children: React.ReactNode }) {

export default function App() {
return (
<ThemeProvider>
<LayoutProvider>
<Outlet />
</LayoutProvider>
</ThemeProvider>
<AuthProvider>
<ThemeProvider>
<LayoutProvider>
<Outlet />
</LayoutProvider>
</ThemeProvider>
</AuthProvider>
);
}

export function HydrateFallback() {
return <p>Loading...</p>;
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
<LoadingSpinner />
</div>
);
}
17 changes: 16 additions & 1 deletion ui/admin/app/routes/_auth.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col h-screen w-screen">
Expand Down
9 changes: 9 additions & 0 deletions ui/admin/app/routes/sign-in.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import SignIn from "~/components/signin/SignIn";

export default function SignInPage() {
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
<SignIn className="w-full max-w-md" />
</div>
);
}
10 changes: 10 additions & 0 deletions ui/admin/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ui/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit 071234d

Please sign in to comment.