Files
zeekayandHanzo Dev 7d293212cb refactor: web/ is now console/ — bootnode/bootnode is the backend, bootnode/console the frontend
Names the two halves for what they are. The repo already holds both (12407e9 merged
bootnode/bootnode into this history); this makes the frontend directory say so, so the layout
reads the way the owner describes the system: backend and frontend separate, one repo.

git mv throughout — 187 renames, history preserved. Every path reference follows: hanzo.yml
build context and dockerfile, compose.yml, compose.prod.yml, start.sh, LLM.md,
infra/k8s/web-deployments.yaml, infra/cloud/deploy.sh, and the Dockerfile's own COPY paths.
Image and container names (bootnode-web, the -web tag suffix) are deliberately NOT renamed here:
those are a deploy contract the App CR and ingress bind to on port 3001, and breaking them is a
separate, staged change.

Console builds green after the move; the Go tree builds.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 17:47:51 -07:00

269 lines
8.0 KiB
TypeScript

// 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<void>
loginWithToken: (token: string) => Promise<void>
register: (email: string, password: string, name: string) => Promise<void>
logout: () => void
hasRole: (role: string) => boolean
hasPermission: (permission: string) => boolean
isAdmin: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(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<BootnodeUser | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
// 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 (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium text-white ${config.color}`}>
{config.name}
</span>
)
}
// 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 <Loading full label="Checking your session…" />
if (requiredRole && !hasRole(requiredRole)) {
return <ErrorState className="py-24" title="Access denied" description={`This page requires the ${requiredRole} role.`} />
}
if (requiredPermission && !hasPermission(requiredPermission)) {
return (
<ErrorState
className="py-24"
title="Access denied"
description={`This page requires the ${requiredPermission} permission.`}
/>
)
}
return <>{children}</>
}