mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
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]>
269 lines
8.0 KiB
TypeScript
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}</>
|
|
}
|