// Bootnode authentication. // Local development: the built-in email/password form. // Every deployed host: that host's own IAM, via PKCE (see lib/iam.ts). // Which issuer that is, is decided by the hostname — see lib/brand.ts. "use client" import { createContext, useContext, useEffect, useState } from "react" import { useRouter } from "next/navigation" import { toast } from "sonner" import { getApiUrl } from "@/lib/api" import { ErrorState, Loading } from "@/components/ui/states" interface BootnodeUser { id: string name: string email: string org: "hanzo" | "zoo" | "lux" | "pars" | "local" avatar?: string roles: string[] permissions: string[] createdAt?: string } interface AuthContextType { user: BootnodeUser | null isLoading: boolean isProduction: boolean login: (email: string, password: string) => Promise loginWithToken: (token: string) => Promise register: (email: string, password: string, name: string) => Promise logout: () => void hasRole: (role: string) => boolean hasPermission: (permission: string) => boolean isAdmin: boolean } const AuthContext = createContext(undefined) // Any host that is not a developer's own machine authenticates through IAM. // // This used to be forceable with NEXT_PUBLIC_AUTH_MODE=iam. That was a // build-time constant baked into one image that serves every white-label host // — the same defect as NEXT_PUBLIC_BRAND — and it was redundant besides: the // hostname test below already answers correctly for every deployed host. const isProductionMode = () => { if (typeof window === "undefined") return false const h = window.location.hostname return h !== "localhost" && h !== "127.0.0.1" && h !== "0.0.0.0" && !h.startsWith("192.168.") && !h.startsWith("10.") } export function useAuth() { const context = useContext(AuthContext) if (context === undefined) { throw new Error("useAuth must be used within an AuthProvider") } return context } interface AuthProviderProps { children: React.ReactNode } export function AuthProvider({ children }: AuthProviderProps) { const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) const isProduction = isProductionMode() // The API origin comes from lib/api's getApiUrl() — the ONE resolver, which // already returns the localStorage override when set and the brand's origin // otherwise. // // A local copy of that logic used to live here, and it was infinitely // recursive: `const getApiUrl = useCallback(...)` shadowed the import at the // top of this file, and its own last line called `getApiUrl()`. On any host // it classified as local — localhost, 127.0.0.1, 192.168.*, 10.* — every // checkAuth / login / register overflowed the stack. That is the whole of // local development, and it is what two copies of one function buys you. useEffect(() => { checkAuth() }, []) async function checkAuth() { try { const token = localStorage.getItem("bootnode_token") if (!token) { setIsLoading(false) return } const apiUrl = getApiUrl() const response = await fetch(`${apiUrl}/v1/auth/me`, { headers: { Authorization: `Bearer ${token}`, }, }) if (response.ok) { const userData = await response.json() setUser(userData) } else { // Token is invalid or expired localStorage.removeItem("bootnode_token") } } catch (error) { console.error("Auth check failed:", error) localStorage.removeItem("bootnode_token") } finally { setIsLoading(false) } } async function login(email: string, password: string) { try { const apiUrl = getApiUrl() const response = await fetch(`${apiUrl}/v1/auth/login`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email, password }), }) if (!response.ok) { const error = await response.json().catch(() => ({ detail: "Login failed" })) throw new Error(error.detail || "Login failed") } const { access_token, user: userData } = await response.json() localStorage.setItem("bootnode_token", access_token) setUser(userData) toast.success("Successfully logged in!") } catch (error) { console.error("Login failed:", error) throw error } } async function loginWithToken(token: string) { try { localStorage.setItem("bootnode_token", token) await checkAuth() toast.success("Successfully logged in!") } catch (error) { console.error("Token login failed:", error) localStorage.removeItem("bootnode_token") throw error } } async function register(email: string, password: string, name: string) { try { const apiUrl = getApiUrl() const response = await fetch(`${apiUrl}/v1/auth/register`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email, password, name }), }) if (!response.ok) { const error = await response.json().catch(() => ({ detail: "Registration failed" })) throw new Error(error.detail || "Registration failed") } const { access_token, user: userData } = await response.json() localStorage.setItem("bootnode_token", access_token) setUser(userData) toast.success("Account created successfully!") } catch (error) { console.error("Registration failed:", error) throw error } } function logout() { localStorage.removeItem("bootnode_token") localStorage.removeItem("bootnode_project_id") setUser(null) toast.success("Successfully logged out!") } function hasRole(role: string): boolean { return user?.roles.includes(role) ?? false } function hasPermission(permission: string): boolean { return user?.permissions.includes(permission) ?? false } const isAdmin = user?.roles.includes("admin") ?? false const value = { user, isLoading, isProduction, login, loginWithToken, register, logout, hasRole, hasPermission, isAdmin, } return {children} } // Organization Badge Component export function OrgBadge({ org }: { org: string }) { const orgConfig = { hanzo: { name: "Hanzo", color: "bg-blue-500" }, zoo: { name: "Zoo Labs", color: "bg-green-500" }, lux: { name: "Lux Network", color: "bg-purple-500" }, pars: { name: "Pars", color: "bg-orange-500" }, local: { name: "Local", color: "bg-gray-500" }, } const config = orgConfig[org as keyof typeof orgConfig] || orgConfig.local return ( {config.name} ) } // Protected Route Component export function ProtectedRoute({ children, requiredRole, requiredPermission }: { children: React.ReactNode requiredRole?: string requiredPermission?: string }) { const { user, isLoading, hasRole, hasPermission, isProduction } = useAuth() const router = useRouter() useEffect(() => { if (!isLoading && !user) { router.push("/login") } }, [user, isLoading, router]) // Checking the session, and the moment after it comes back empty and before // the redirect to /login lands, are the same state to the reader: "not yet". if (isLoading || !user) return if (requiredRole && !hasRole(requiredRole)) { return } if (requiredPermission && !hasPermission(requiredPermission)) { return ( ) } return <>{children} }