diff --git a/llama_stack/ui/app/api/auth/[...path]/route.ts b/llama_stack/ui/app/api/auth/[...path]/route.ts new file mode 100644 index 000000000..dd1ca2d48 --- /dev/null +++ b/llama_stack/ui/app/api/auth/[...path]/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { BACKEND_URL } from "@/lib/server-config"; + +async function proxyAuthRequest(request: NextRequest, method: string) { + try { + const url = new URL(request.url); + const pathSegments = url.pathname.split("/"); + + // Remove /api/auth from the path to get the actual auth path + // /api/auth/github/login -> /auth/github/login + const authPath = pathSegments.slice(3).join("/"); // Remove 'api' and 'auth' segments + const targetUrl = `${BACKEND_URL}/auth/${authPath}${url.search}`; + + // Prepare headers (exclude host and other problematic headers) + const headers = new Headers(); + request.headers.forEach((value, key) => { + // Skip headers that might cause issues in proxy + if ( + !["host", "connection", "content-length"].includes(key.toLowerCase()) + ) { + headers.set(key, value); + } + }); + + const requestOptions: RequestInit = { + method, + headers, + // Don't follow redirects automatically - we need to handle them + redirect: "manual", + }; + + if (["POST", "PUT", "PATCH"].includes(method) && request.body) { + requestOptions.body = await request.text(); + } + + const response = await fetch(targetUrl, requestOptions); + + // Handle redirects + if (response.status === 302 || response.status === 307) { + const location = response.headers.get("location"); + if (location) { + // For external redirects (like GitHub OAuth), return the redirect + return NextResponse.redirect(location); + } + } + + if (response.ok) { + const responseText = await response.text(); + + const proxyResponse = new NextResponse(responseText, { + status: response.status, + statusText: response.statusText, + }); + + response.headers.forEach((value, key) => { + if (!["connection", "transfer-encoding"].includes(key.toLowerCase())) { + proxyResponse.headers.set(key, value); + } + }); + + return proxyResponse; + } + + const errorText = await response.text(); + return new NextResponse(errorText, { + status: response.status, + statusText: response.statusText, + }); + } catch (error) { + return NextResponse.json( + { + error: "Auth proxy request failed", + message: error instanceof Error ? error.message : "Unknown error", + backend_url: BACKEND_URL, + timestamp: new Date().toISOString(), + }, + { status: 500 }, + ); + } +} + +export async function GET(request: NextRequest) { + return proxyAuthRequest(request, "GET"); +} + +export async function POST(request: NextRequest) { + return proxyAuthRequest(request, "POST"); +} diff --git a/llama_stack/ui/app/api/v1/[...path]/route.ts b/llama_stack/ui/app/api/v1/[...path]/route.ts index 1959f9099..ee2030eaf 100644 --- a/llama_stack/ui/app/api/v1/[...path]/route.ts +++ b/llama_stack/ui/app/api/v1/[...path]/route.ts @@ -1,9 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; - -// Get backend URL from environment variable or default to localhost for development -const BACKEND_URL = - process.env.LLAMA_STACK_BACKEND_URL || - `http://localhost:${process.env.LLAMA_STACK_PORT || 8321}`; +import { BACKEND_URL } from "@/lib/server-config"; async function proxyRequest(request: NextRequest, method: string) { try { @@ -16,8 +12,6 @@ async function proxyRequest(request: NextRequest, method: string) { const apiPath = pathSegments.slice(2).join("/"); // Remove 'api' segment const targetUrl = `${BACKEND_URL}/${apiPath}${url.search}`; - console.log(`Proxying ${method} ${url.pathname} -> ${targetUrl}`); - // Prepare headers (exclude host and other problematic headers) const headers = new Headers(); request.headers.forEach((value, key) => { @@ -33,6 +27,7 @@ async function proxyRequest(request: NextRequest, method: string) { const requestOptions: RequestInit = { method, headers, + redirect: apiPath.startsWith("auth/") ? "manual" : "follow", }; // Add body for methods that support it @@ -43,6 +38,18 @@ async function proxyRequest(request: NextRequest, method: string) { // Make the request to FastAPI backend const response = await fetch(targetUrl, requestOptions); + // Handle redirects for auth routes + if ( + response.type === "opaqueredirect" || + response.status === 302 || + response.status === 307 + ) { + const location = response.headers.get("location"); + if (location) { + return NextResponse.redirect(location); + } + } + // Get response data const responseText = await response.text(); diff --git a/llama_stack/ui/app/auth/github/callback/page.tsx b/llama_stack/ui/app/auth/github/callback/page.tsx new file mode 100644 index 000000000..fc48d1690 --- /dev/null +++ b/llama_stack/ui/app/auth/github/callback/page.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/contexts/auth-context"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export default function AuthCallbackPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { login } = useAuth(); + const [error, setError] = useState(null); + const [isProcessing, setIsProcessing] = useState(true); + + useEffect(() => { + const processCallback = async () => { + // Get token from URL fragment (hash) + const hash = window.location.hash.substring(1); // Remove the # + const params = new URLSearchParams(hash); + const token = params.get("token"); + + if (!token) { + setError("Missing authentication token"); + setIsProcessing(false); + return; + } + + try { + // Store the token and decode user info from JWT + await login(token); + setIsProcessing(false); + } catch (err) { + setError("Failed to complete authentication"); + setIsProcessing(false); + } + }; + + processCallback(); + }, [searchParams, login]); + + if (error) { + return ( +
+ + + Authentication Error + {error} + + + + + +
+ ); + } + + return ( +
+ + + Authenticating... + + Please wait while we complete your login. + + + +
+
+
+
+ ); +} diff --git a/llama_stack/ui/app/layout.tsx b/llama_stack/ui/app/layout.tsx index ed8a6cd5d..16caf3aae 100644 --- a/llama_stack/ui/app/layout.tsx +++ b/llama_stack/ui/app/layout.tsx @@ -2,6 +2,9 @@ import type { Metadata } from "next"; import { ThemeProvider } from "@/components/ui/theme-provider"; import { Geist, Geist_Mono } from "next/font/google"; import { ModeToggle } from "@/components/ui/mode-toggle"; +import { AuthProvider } from "@/contexts/auth-context"; +import { UserMenu } from "@/components/auth/user-menu"; +import { Toaster } from "@/components/ui/sonner"; import "./globals.css"; const geistSans = Geist({ @@ -32,22 +35,26 @@ export default function Layout({ children }: { children: React.ReactNode }) { enableSystem disableTransitionOnChange > - - -
- {/* Header with aligned elements */} -
-
- + + + +
+ {/* Header with aligned elements */} +
+
+ +
+
+
+ + +
-
-
- -
-
-
{children}
-
-
+
{children}
+ + + + diff --git a/llama_stack/ui/app/login/page.tsx b/llama_stack/ui/app/login/page.tsx new file mode 100644 index 000000000..4aa6b245b --- /dev/null +++ b/llama_stack/ui/app/login/page.tsx @@ -0,0 +1,6 @@ +import { LoginPage } from "@/components/auth/login-page"; +import { serverConfig } from "@/lib/server-config"; + +export default function LoginPageRoute() { + return ; +} diff --git a/llama_stack/ui/app/logs/chat-completions/[id]/page.tsx b/llama_stack/ui/app/logs/chat-completions/[id]/page.tsx index e6feef363..c52b49875 100644 --- a/llama_stack/ui/app/logs/chat-completions/[id]/page.tsx +++ b/llama_stack/ui/app/logs/chat-completions/[id]/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { ChatCompletion } from "@/lib/types"; import { ChatCompletionDetailView } from "@/components/chat-completions/chat-completion-detail"; -import { client } from "@/lib/client"; +import { getClient } from "@/lib/client"; export default function ChatCompletionDetailPage() { const params = useParams(); @@ -27,7 +27,7 @@ export default function ChatCompletionDetailPage() { setError(null); setCompletionDetail(null); try { - const response = await client.chat.completions.retrieve(id); + const response = await getClient().chat.completions.retrieve(id); setCompletionDetail(response as ChatCompletion); } catch (err) { console.error( diff --git a/llama_stack/ui/app/logs/responses/[id]/page.tsx b/llama_stack/ui/app/logs/responses/[id]/page.tsx index efe6f0ff3..c27135833 100644 --- a/llama_stack/ui/app/logs/responses/[id]/page.tsx +++ b/llama_stack/ui/app/logs/responses/[id]/page.tsx @@ -5,7 +5,7 @@ import { useParams } from "next/navigation"; import type { ResponseObject } from "llama-stack-client/resources/responses/responses"; import { OpenAIResponse, InputItemListResponse } from "@/lib/types"; import { ResponseDetailView } from "@/components/responses/responses-detail"; -import { client } from "@/lib/client"; +import { getClient } from "@/lib/client"; export default function ResponseDetailPage() { const params = useParams(); @@ -59,6 +59,8 @@ export default function ResponseDetailPage() { setResponseDetail(null); setInputItems(null); + const client = getClient(); + try { const [responseResult, inputItemsResult] = await Promise.allSettled([ client.responses.retrieve(id), diff --git a/llama_stack/ui/components/auth/login-button.tsx b/llama_stack/ui/components/auth/login-button.tsx new file mode 100644 index 000000000..474a86761 --- /dev/null +++ b/llama_stack/ui/components/auth/login-button.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { LucideIcon } from "lucide-react"; + +interface LoginButtonProps { + provider: { + id: string; + name: string; + icon: LucideIcon; + loginPath: string; + buttonColor?: string; + }; + className?: string; +} + +export function LoginButton({ provider, className }: LoginButtonProps) { + const handleLogin = async () => { + // Add redirect_url parameter to tell backend where to redirect after OAuth + const redirectUrl = `${window.location.origin}/auth/github/callback`; + const loginUrl = `${provider.loginPath}?redirect_url=${encodeURIComponent(redirectUrl)}`; + window.location.href = loginUrl; + }; + + const Icon = provider.icon; + + return ( + + ); +} diff --git a/llama_stack/ui/components/auth/login-page.tsx b/llama_stack/ui/components/auth/login-page.tsx new file mode 100644 index 000000000..1f234e9b6 --- /dev/null +++ b/llama_stack/ui/components/auth/login-page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import React from "react"; +import { useAuth } from "@/contexts/auth-context"; +import { LoginButton } from "@/components/auth/login-button"; +import { GitHubIcon } from "@/components/icons/github-icon"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { LucideIcon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +interface AuthProvider { + id: string; + name: string; + icon: LucideIcon; + loginPath: string; + buttonColor?: string; +} + +interface LoginPageProps { + backendUrl: string; +} + +export function LoginPage({ backendUrl }: LoginPageProps) { + const { isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const error = searchParams.get("error"); + + // Define available auth providers + const authProviders: AuthProvider[] = [ + { + id: "github", + name: "GitHub", + icon: GitHubIcon as LucideIcon, + loginPath: `${backendUrl}/auth/github/login`, + buttonColor: "bg-gray-900 hover:bg-gray-800 text-white", + }, + // Future providers can be added here + ]; + + useEffect(() => { + if (!isLoading && isAuthenticated) { + router.push("/"); + } + }, [isAuthenticated, isLoading, router]); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+ + + + Welcome to Llama Stack + + + {error === "auth_not_configured" + ? "Authentication is not configured on this server" + : "Sign in to access your logs and resources"} + + + + {error !== "auth_not_configured" && ( + <> + {authProviders.length > 1 && ( + <> +
+ Continue with +
+ + + )} + +
+ {authProviders.map((provider) => ( + + ))} +
+ + )} +
+
+
+ ); +} diff --git a/llama_stack/ui/components/auth/user-menu.tsx b/llama_stack/ui/components/auth/user-menu.tsx new file mode 100644 index 000000000..73c372636 --- /dev/null +++ b/llama_stack/ui/components/auth/user-menu.tsx @@ -0,0 +1,156 @@ +"use client"; + +import React from "react"; +import { useAuth } from "@/contexts/auth-context"; +import { useAuthConfig } from "@/hooks/use-auth-config"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { User, LogOut, Key, Clock } from "lucide-react"; +import { getAuthToken, isTokenExpired } from "@/lib/auth"; +import { toast } from "sonner"; + +export function UserMenu() { + const { user, logout, isAuthenticated } = useAuth(); + const { isAuthConfigured } = useAuthConfig(); + + const handleCopyToken = async () => { + const token = getAuthToken(); + if (!token) { + toast.error("No authentication token found"); + return; + } + + try { + await navigator.clipboard.writeText(token); + toast.success("API token copied to clipboard"); + } catch (error) { + toast.error("Failed to copy token to clipboard"); + } + }; + + const getTokenExpiryInfo = () => { + const token = getAuthToken(); + if (!token) return null; + + try { + const payload = JSON.parse(atob(token.split(".")[1])); + const exp = payload.exp; + if (!exp) return null; + + const expiryDate = new Date(exp * 1000); + const now = new Date(); + const hoursRemaining = Math.max( + 0, + (expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60), + ); + + if (hoursRemaining < 1) { + return `Expires in ${Math.round(hoursRemaining * 60)} minutes`; + } else if (hoursRemaining < 24) { + return `Expires in ${Math.round(hoursRemaining)} hours`; + } else { + return `Expires in ${Math.round(hoursRemaining / 24)} days`; + } + } catch { + return null; + } + }; + + if (!isAuthenticated || !user) { + if (!isAuthConfigured) { + return ( + + + + + + + +

Authentication is not configured on this server

+
+
+ ); + } + + return ( + + ); + } + + return ( + + + + + + +
+

+ {user.name || user.username} +

+ {user.email && ( +

+ {user.email} +

+ )} +
+
+ + + + Copy API Token + + {getTokenExpiryInfo() && ( + + + {getTokenExpiryInfo()} + + )} + + {user.organizations && user.organizations.length > 0 && ( + <> + + Organizations + + {user.organizations.map((org) => ( + + {org} + + ))} + + + )} + + + Log out + +
+
+ ); +} diff --git a/llama_stack/ui/components/chat-completions/chat-completions-table.tsx b/llama_stack/ui/components/chat-completions/chat-completions-table.tsx index d5838d877..78872cf56 100644 --- a/llama_stack/ui/components/chat-completions/chat-completions-table.tsx +++ b/llama_stack/ui/components/chat-completions/chat-completions-table.tsx @@ -11,7 +11,7 @@ import { extractDisplayableText, } from "@/lib/format-message-content"; import { usePagination } from "@/hooks/usePagination"; -import { client } from "@/lib/client"; +import { getClient } from "@/lib/client"; interface ChatCompletionsTableProps { /** Optional pagination configuration */ @@ -38,7 +38,7 @@ export function ChatCompletionsTable({ model?: string; order?: string; }) => { - const response = await client.chat.completions.list({ + const response = await getClient().chat.completions.list({ after: params.after, limit: params.limit, ...(params.model && { model: params.model }), diff --git a/llama_stack/ui/components/icons/github-icon.tsx b/llama_stack/ui/components/icons/github-icon.tsx new file mode 100644 index 000000000..6ae4fe534 --- /dev/null +++ b/llama_stack/ui/components/icons/github-icon.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { LucideProps } from "lucide-react"; + +export function GitHubIcon(props: LucideProps) { + return ( + + + + ); +} diff --git a/llama_stack/ui/components/responses/responses-table.tsx b/llama_stack/ui/components/responses/responses-table.tsx index 116a2adbe..d3737bfcc 100644 --- a/llama_stack/ui/components/responses/responses-table.tsx +++ b/llama_stack/ui/components/responses/responses-table.tsx @@ -7,7 +7,7 @@ import { } from "@/lib/types"; import { LogsTable, LogTableRow } from "@/components/logs/logs-table"; import { usePagination } from "@/hooks/usePagination"; -import { client } from "@/lib/client"; +import { getClient } from "@/lib/client"; import type { ResponseListResponse } from "llama-stack-client/resources/responses/responses"; import { isMessageInput, @@ -131,7 +131,7 @@ export function ResponsesTable({ paginationOptions }: ResponsesTableProps) { model?: string; order?: string; }) => { - const response = await client.responses.list({ + const response = await getClient().responses.list({ after: params.after, limit: params.limit, ...(params.model && { model: params.model }), diff --git a/llama_stack/ui/components/ui/sonner.tsx b/llama_stack/ui/components/ui/sonner.tsx new file mode 100644 index 000000000..f1259836a --- /dev/null +++ b/llama_stack/ui/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Toaster as Sonner, ToasterProps } from "sonner"; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/llama_stack/ui/contexts/auth-context.tsx b/llama_stack/ui/contexts/auth-context.tsx new file mode 100644 index 000000000..04bcfd45c --- /dev/null +++ b/llama_stack/ui/contexts/auth-context.tsx @@ -0,0 +1,115 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, +} from "react"; +import { useRouter } from "next/navigation"; +import { + User, + getAuthToken, + setAuthToken, + getStoredUser, + setStoredUser, + clearAuth, + isTokenExpired, +} from "@/lib/auth"; + +interface AuthContextType { + user: User | null; + isLoading: boolean; + isAuthenticated: boolean; + login: (token: string) => Promise; + logout: () => void; + checkAuth: () => void; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const router = useRouter(); + + const checkAuth = useCallback(() => { + const token = getAuthToken(); + const storedUser = getStoredUser(); + + if (token && storedUser && !isTokenExpired(token)) { + setUser(storedUser); + } else { + clearAuth(); + setUser(null); + } + setIsLoading(false); + }, []); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + const login = useCallback( + async (token: string) => { + try { + // Decode JWT to get user info + const base64Url = token.split(".")[1]; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const jsonPayload = decodeURIComponent( + atob(base64) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join(""), + ); + + const claims = JSON.parse(jsonPayload); + + // Extract user info from JWT claims + const userInfo: User = { + username: claims.github_username || claims.sub, + email: claims.email, + name: claims.name, + avatar_url: claims.avatar_url, + organizations: claims.github_orgs, + }; + + setAuthToken(token); + setStoredUser(userInfo); + setUser(userInfo); + router.push("/"); // Redirect to home after login + } catch (error) { + // Clear token if we can't decode it + clearAuth(); + throw new Error("Failed to decode authentication token"); + } + }, + [router], + ); + + const logout = useCallback(() => { + clearAuth(); + setUser(null); + router.push("/login"); + }, [router]); + + const value = { + user, + isLoading, + isAuthenticated: !!user, + login, + logout, + checkAuth, + }; + + return {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/llama_stack/ui/hooks/use-auth-config.ts b/llama_stack/ui/hooks/use-auth-config.ts new file mode 100644 index 000000000..2a4a3665b --- /dev/null +++ b/llama_stack/ui/hooks/use-auth-config.ts @@ -0,0 +1,28 @@ +import { useState, useEffect } from "react"; + +export function useAuthConfig() { + const [isAuthConfigured, setIsAuthConfigured] = useState(true); + const [isChecking, setIsChecking] = useState(true); + + useEffect(() => { + const checkAuthConfig = async () => { + try { + const response = await fetch("/api/auth/github/login", { + method: "HEAD", + redirect: "manual", + }); + + setIsAuthConfigured(response.status !== 404); + } catch (error) { + console.error("Auth config check error:", error); + setIsAuthConfigured(true); + } finally { + setIsChecking(false); + } + }; + + checkAuthConfig(); + }, []); + + return { isAuthConfigured, isChecking }; +} diff --git a/llama_stack/ui/hooks/usePagination.ts b/llama_stack/ui/hooks/usePagination.ts index 55a8d4ac8..0c4f2fd9f 100644 --- a/llama_stack/ui/hooks/usePagination.ts +++ b/llama_stack/ui/hooks/usePagination.ts @@ -2,6 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { PaginationStatus, UsePaginationOptions } from "@/lib/types"; +import { useRouter } from "next/navigation"; +import { AuthenticationError } from "llama-stack-client"; interface PaginationState { data: T[]; @@ -51,6 +53,7 @@ export function usePagination({ error: null, lastId: null, }); + const router = useRouter(); // Use refs to avoid stale closures const stateRef = useRef(state); @@ -91,6 +94,12 @@ export function usePagination({ status: "idle", })); } catch (err) { + // Handle authentication errors by redirecting to login + if (err instanceof AuthenticationError) { + router.push("/login"); + return; + } + const errorMessage = isInitialLoad ? `Failed to load ${errorMessagePrefix}. Please try refreshing the page.` : `Failed to load more ${errorMessagePrefix}. Please try again.`; @@ -107,7 +116,7 @@ export function usePagination({ })); } }, - [limit, model, order, fetchFunction, errorMessagePrefix], + [limit, model, order, fetchFunction, errorMessagePrefix, router], ); /** diff --git a/llama_stack/ui/lib/auth.ts b/llama_stack/ui/lib/auth.ts new file mode 100644 index 000000000..0ab333f9d --- /dev/null +++ b/llama_stack/ui/lib/auth.ts @@ -0,0 +1,77 @@ +export interface User { + username: string; + email?: string; + name?: string; + avatar_url?: string; + organizations?: string[]; +} + +export interface AuthResponse { + access_token: string; + token_type: string; + expires_in: number; + user_info: User; +} + +const TOKEN_KEY = "llama_stack_token"; +const USER_KEY = "llama_stack_user"; + +export function getAuthToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(TOKEN_KEY); +} + +export function setAuthToken(token: string): void { + if (typeof window !== "undefined") { + localStorage.setItem(TOKEN_KEY, token); + } +} + +export function removeAuthToken(): void { + if (typeof window !== "undefined") { + localStorage.removeItem(TOKEN_KEY); + } +} + +export function getStoredUser(): User | null { + if (typeof window === "undefined") return null; + const userStr = localStorage.getItem(USER_KEY); + if (!userStr) return null; + try { + return JSON.parse(userStr); + } catch { + return null; + } +} + +export function setStoredUser(user: User): void { + if (typeof window !== "undefined") { + localStorage.setItem(USER_KEY, JSON.stringify(user)); + } +} + +export function removeStoredUser(): void { + if (typeof window !== "undefined") { + localStorage.removeItem(USER_KEY); + } +} + +export function clearAuth(): void { + removeAuthToken(); + removeStoredUser(); +} + +export function isAuthenticated(): boolean { + return !!getAuthToken(); +} + +export function isTokenExpired(token: string): boolean { + try { + const payload = JSON.parse(atob(token.split(".")[1])); + const exp = payload.exp; + if (!exp) return false; + return Date.now() >= exp * 1000; + } catch { + return true; + } +} diff --git a/llama_stack/ui/lib/client.ts b/llama_stack/ui/lib/client.ts index 8492496e2..0bb05a99a 100644 --- a/llama_stack/ui/lib/client.ts +++ b/llama_stack/ui/lib/client.ts @@ -1,6 +1,12 @@ import LlamaStackClient from "llama-stack-client"; +import { getAuthToken } from "./auth"; -export const client = new LlamaStackClient({ - baseURL: - typeof window !== "undefined" ? `${window.location.origin}/api` : "/api", -}); +export function getClient() { + const token = getAuthToken(); + + return new LlamaStackClient({ + baseURL: + typeof window !== "undefined" ? `${window.location.origin}/api` : "/api", + apiKey: token || undefined, + }); +} diff --git a/llama_stack/ui/lib/server-config.ts b/llama_stack/ui/lib/server-config.ts new file mode 100644 index 000000000..fd3432665 --- /dev/null +++ b/llama_stack/ui/lib/server-config.ts @@ -0,0 +1,15 @@ +/** + * Server-side configuration for the Llama Stack UI + * This file should only be imported in server components + */ + +// Get backend URL from environment variable or default to localhost for development +export const BACKEND_URL = + process.env.LLAMA_STACK_BACKEND_URL || + `http://localhost:${process.env.LLAMA_STACK_PORT || 8321}`; + +export const serverConfig = { + backendUrl: BACKEND_URL, +} as const; + +export default serverConfig; diff --git a/llama_stack/ui/package-lock.json b/llama_stack/ui/package-lock.json index 3c60dbb39..4545655e0 100644 --- a/llama_stack/ui/package-lock.json +++ b/llama_stack/ui/package-lock.json @@ -15,12 +15,13 @@ "@radix-ui/react-tooltip": "^1.2.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "llama-stack-client": "0.2.9", + "llama-stack-client": "0.2.12", "lucide-react": "^0.510.0", "next": "15.3.2", "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "sonner": "^2.0.5", "tailwind-merge": "^3.3.0" }, "devDependencies": { @@ -9529,9 +9530,9 @@ "license": "MIT" }, "node_modules/llama-stack-client": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/llama-stack-client/-/llama-stack-client-0.2.9.tgz", - "integrity": "sha512-7+2WuPYt2j/k/Twh5IGn8hd8q4W6lVEK+Ql4PpICGLj4N8YmooCfydI1UvdT2UlX7PNYKNeyeFqTifWT2MjWKg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/llama-stack-client/-/llama-stack-client-0.2.12.tgz", + "integrity": "sha512-egjjigck1ZnVdEkfbQ/U7whv9sqIT9iiDAnk6C6DV3g6v8wzIfT85mAKEr/RoYxJwzD/Ofltf9ovFLty5iF4QA==", "license": "Apache-2.0", "dependencies": { "@types/node": "^18.11.18", @@ -11476,6 +11477,16 @@ "node": ">=8" } }, + "node_modules/sonner": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.5.tgz", + "integrity": "sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/llama_stack/ui/package.json b/llama_stack/ui/package.json index af9165256..01aa879fd 100644 --- a/llama_stack/ui/package.json +++ b/llama_stack/ui/package.json @@ -26,6 +26,7 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "sonner": "^2.0.5", "tailwind-merge": "^3.3.0" }, "devDependencies": {