HomePilot / frontend /src /ui /components /AuthGate.tsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
23b413b
/**
* AuthGate β€” wraps the entire app with authentication + onboarding.
*
* Flow:
* 1. On mount, call GET /v1/auth/me to check auth status
* 2. If needs_setup (no users) β†’ show AuthScreen in register mode
* 3. If needs_login (multi-user, no token) β†’ show AuthScreen
* 4. If authenticated but !onboarding_complete β†’ show OnboardingWizard
* 5. If authenticated and onboarded β†’ render children (the main App)
*
* Single-user with no password: auto-login, no friction.
*
* Provides AuthContext so child components (e.g. App.tsx) can call
* `logout()` for a smooth transition back to the login screen
* without a hard page reload.
*/
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
import AuthScreen from './AuthScreen'
import OnboardingWizard from './OnboardingWizard'
import { resolveBackendUrl } from '../lib/backendUrl'
export interface AuthUser {
id: string
username: string
display_name: string
email: string
avatar_url: string
onboarding_complete: boolean
}
// ── Recent-users helpers (localStorage-backed) ────────────────────────────────
export interface RecentUser {
username: string
display_name: string
avatar_url: string
lastLogin: number // epoch ms
}
const LS_TOKEN_KEY = 'homepilot_auth_token'
const LS_USER_KEY = 'homepilot_auth_user'
const LS_RECENT_USERS_KEY = 'homepilot_recent_users'
function getBackendUrl(): string {
return resolveBackendUrl()
}
function loadRecentUsers(): RecentUser[] {
try {
const raw = localStorage.getItem(LS_RECENT_USERS_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as RecentUser[]
// Sort by most recent first, keep max 5
return parsed.sort((a, b) => b.lastLogin - a.lastLogin).slice(0, 5)
} catch {
return []
}
}
function saveRecentUser(user: AuthUser) {
const existing = loadRecentUsers().filter(u => u.username !== user.username)
const entry: RecentUser = {
username: user.username,
display_name: user.display_name,
avatar_url: user.avatar_url,
lastLogin: Date.now(),
}
const updated = [entry, ...existing].slice(0, 5)
localStorage.setItem(LS_RECENT_USERS_KEY, JSON.stringify(updated))
}
// ── Auth context ──────────────────────────────────────────────────────────────
interface AuthContextValue {
user: AuthUser | null
token: string
logout: () => Promise<void>
/** Merge partial updates into the current user (e.g. after avatar upload or profile save). */
updateUser: (partial: Partial<AuthUser>) => void
}
const AuthContext = createContext<AuthContextValue>({
user: null,
token: '',
logout: async () => {},
updateUser: () => {},
})
export function useAuth(): AuthContextValue {
return useContext(AuthContext)
}
// ── AuthGate component ────────────────────────────────────────────────────────
interface AuthGateProps {
children: React.ReactNode
}
export default function AuthGate({ children }: AuthGateProps) {
const [state, setState] = useState<'loading' | 'login' | 'onboarding' | 'ready'>('loading')
const [user, setUser] = useState<AuthUser | null>(null)
const [token, setToken] = useState<string>('')
const [loggedOutMessage, setLoggedOutMessage] = useState<string>('')
const [recentUsers, setRecentUsers] = useState<RecentUser[]>([])
const backendUrl = getBackendUrl()
useEffect(() => {
checkAuth()
}, [])
async function checkAuth() {
const savedToken = localStorage.getItem(LS_TOKEN_KEY) || ''
try {
const res = await fetch(`${backendUrl}/v1/auth/me`, {
headers: savedToken ? { 'Authorization': `Bearer ${savedToken}` } : {},
credentials: 'include',
})
if (!res.ok) {
// Backend might not have the /v1/auth routes yet (old version)
// β†’ just let through (backward compatible)
setState('ready')
return
}
const data = await res.json()
if (data.needs_setup) {
// First boot β€” no users exist
setState('login')
return
}
if (data.needs_login) {
// Multi-user, need to log in
setRecentUsers(loadRecentUsers())
setState('login')
return
}
if (data.user) {
const u = data.user as AuthUser
const t = data.token || savedToken
setUser(u)
setToken(t)
if (t) localStorage.setItem(LS_TOKEN_KEY, t)
localStorage.setItem(LS_USER_KEY, JSON.stringify(u))
saveRecentUser(u)
if (!u.onboarding_complete) {
setState('onboarding')
} else {
setState('ready')
}
return
}
// Fallback: let through
setState('ready')
} catch {
// Backend unreachable β€” skip auth (backward compatible with pre-auth setups)
setState('ready')
}
}
function handleAuthenticated(u: AuthUser, t: string) {
setUser(u)
setToken(t)
setLoggedOutMessage('')
localStorage.setItem(LS_TOKEN_KEY, t)
localStorage.setItem(LS_USER_KEY, JSON.stringify(u))
saveRecentUser(u)
if (!u.onboarding_complete) {
setState('onboarding')
} else {
setState('ready')
}
}
function handleOnboardingComplete(displayName: string) {
if (user) {
const updated = { ...user, display_name: displayName, onboarding_complete: true }
setUser(updated)
localStorage.setItem(LS_USER_KEY, JSON.stringify(updated))
}
setState('ready')
}
// Update user fields live (e.g. after avatar upload or display name change)
const updateUser = useCallback((partial: Partial<AuthUser>) => {
setUser(prev => {
if (!prev) return prev
const updated = { ...prev, ...partial }
localStorage.setItem(LS_USER_KEY, JSON.stringify(updated))
// Also update recent-users cache so the login screen shows fresh data
saveRecentUser(updated)
return updated
})
}, [])
// Smooth logout β€” clears state and transitions to login screen without reload
const logout = useCallback(async () => {
const savedToken = localStorage.getItem(LS_TOKEN_KEY) || ''
const currentDisplayName = user?.display_name || user?.username || ''
// Fire-and-forget backend invalidation
if (savedToken) {
try {
await fetch(`${backendUrl}/v1/auth/logout`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${savedToken}` },
credentials: 'include',
})
} catch { /* non-fatal */ }
}
// Clear stored credentials
localStorage.removeItem(LS_TOKEN_KEY)
localStorage.removeItem(LS_USER_KEY)
// Transition smoothly
setUser(null)
setToken('')
setLoggedOutMessage(
currentDisplayName
? `Signed out as ${currentDisplayName}`
: 'Signed out successfully'
)
setRecentUsers(loadRecentUsers())
setState('login')
}, [user, backendUrl])
// Loading spinner β€” uses same neutral bg as login page
if (state === 'loading') {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#050506',
color: 'rgba(255, 255, 255, 0.30)',
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: 14,
}}>
Loading...
</div>
)
}
// Login / Register screen
if (state === 'login') {
return (
<AuthScreen
backendUrl={backendUrl}
onAuthenticated={handleAuthenticated}
logoutMessage={loggedOutMessage}
recentUsers={recentUsers}
/>
)
}
// Onboarding wizard
if (state === 'onboarding' && user) {
return (
<OnboardingWizard
backendUrl={backendUrl}
token={token}
username={user.username}
onComplete={handleOnboardingComplete}
/>
)
}
// Authenticated and onboarded β€” render the main app with auth context
return (
<AuthContext.Provider value={{ user, token, logout, updateUser }}>
{children}
</AuthContext.Provider>
)
}