File size: 7,726 Bytes
921d377 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | /**
* 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';
const LS_TOKEN_KEY = 'homepilot_auth_token';
const LS_USER_KEY = 'homepilot_auth_user';
const LS_RECENT_USERS_KEY = 'homepilot_recent_users';
function getBackendUrl() {
return resolveBackendUrl();
}
function loadRecentUsers() {
try {
const raw = localStorage.getItem(LS_RECENT_USERS_KEY);
if (!raw)
return [];
const parsed = JSON.parse(raw);
// 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) {
const existing = loadRecentUsers().filter(u => u.username !== user.username);
const entry = {
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));
}
const AuthContext = createContext({
user: null,
token: '',
logout: async () => { },
updateUser: () => { },
});
export function useAuth() {
return useContext(AuthContext);
}
export default function AuthGate({ children }) {
const [state, setState] = useState('loading');
const [user, setUser] = useState(null);
const [token, setToken] = useState('');
const [loggedOutMessage, setLoggedOutMessage] = useState('');
const [recentUsers, setRecentUsers] = useState([]);
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;
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, t) {
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) {
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) => {
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>);
}
|