import { useState, useCallback, useRef, useContext } from 'react'; import bs58 from 'bs58'; import { useDubs } from '../provider'; import { AuthContext } from '../auth-context'; import { useDisconnect } from '../managed-wallet'; import { getDeviceInfo } from '../utils/device'; import type { AuthStatus, DubsUser } from '../types'; import { ensurePngAvatar } from '../utils/avatarUrl'; export interface UseAuthResult { /** Current auth status */ status: AuthStatus; /** Authenticated user profile, or null */ user: DubsUser | null; /** JWT token, or null */ token: string | null; /** Convenience boolean */ isAuthenticated: boolean; /** Error from the last operation, or null */ error: Error | null; /** * Full nonce → sign → verify flow. * If the wallet is already registered, resolves to authenticated state. * If new wallet, resolves to needsRegistration state — call register() next. */ authenticate: () => Promise; /** * Register a new user after authenticate() returned needsRegistration. * Reuses the nonce+signature from authenticate() — no second signing prompt. */ register: (username: string, referralCode?: string, avatarUrl?: string) => Promise; /** Log out and clear state */ logout: () => Promise; /** * Restore a session from a saved token (e.g. AsyncStorage on app restart). * Calls GET /auth/me to validate. Returns true if valid, false otherwise. */ restoreSession: (token: string) => Promise; /** Re-fetch user profile from the server (e.g. after avatar change) */ refreshUser: () => Promise; /** Reset to idle state, clearing errors and user */ reset: () => void; } export function useAuth(): UseAuthResult { // If inside AuthGate, return the shared auth state const sharedAuth = useContext(AuthContext); const { client, wallet } = useDubs(); const disconnect = useDisconnect(); const [status, setStatus] = useState('idle'); const [user, setUser] = useState(null); const [token, setToken] = useState(null); const [error, setError] = useState(null); // Stash nonce+signature+deviceInfo between authenticate → register (single-sign flow) const pendingAuth = useRef<{ walletAddress: string; nonce: string; signature: string; deviceInfo?: import('../utils/device').DeviceInfo; } | null>(null); const normalizeUser = (u: DubsUser): DubsUser => ({ ...u, avatar: ensurePngAvatar(u.avatar) ?? u.avatar, }); const reset = useCallback(() => { setStatus('idle'); setUser(null); setToken(null); setError(null); pendingAuth.current = null; client.setToken(null); }, [client]); const authenticate = useCallback(async () => { try { if (!wallet.publicKey) { throw new Error('Wallet not connected'); } if (!wallet.signMessage) { throw new Error('Wallet does not support signMessage'); } setStatus('authenticating'); setError(null); const walletAddress = wallet.publicKey.toBase58(); // 0. Collect device info const deviceInfo = await getDeviceInfo(); console.log('[useAuth] Device info:', JSON.stringify(deviceInfo, null, 2)); // 1. Get nonce const { nonce, message } = await client.getNonce(walletAddress); // 2. Sign message setStatus('signing'); const messageBytes = new TextEncoder().encode(message); const signatureBytes = await wallet.signMessage!(messageBytes); const signature = bs58.encode(signatureBytes); // 3. Verify with server setStatus('verifying'); const result = await client.authenticate({ walletAddress, signature, nonce, deviceInfo }); if (result.needsRegistration) { // Stash credentials for register() — nonce is NOT consumed pendingAuth.current = { walletAddress, nonce, signature, deviceInfo }; setStatus('needsRegistration'); return; } // Existing user — fully authenticated setUser(normalizeUser(result.user!)); setToken(result.token!); setStatus('authenticated'); } catch (err) { const message = err instanceof Error ? err.message : String(err); // Phantom 4100 = stale/revoked session — clear it and return to connect screen if (message.includes('4100') || message.includes('not been authorized')) { console.log('[useAuth] Stale Phantom session detected (4100), forcing disconnect'); await disconnect?.(); setStatus('idle'); setError(null); return; } setError(err instanceof Error ? err : new Error(message)); setStatus('error'); } }, [client, wallet, disconnect]); const register = useCallback(async (username: string, referralCode?: string, avatarUrl?: string) => { try { const pending = pendingAuth.current; if (!pending) { throw new Error('No pending authentication — call authenticate() first'); } setStatus('registering'); setError(null); const result = await client.register({ walletAddress: pending.walletAddress, signature: pending.signature, nonce: pending.nonce, username, referralCode, avatarUrl, deviceInfo: pending.deviceInfo, }); pendingAuth.current = null; // Use the chosen avatar locally if the backend didn't store it const user = avatarUrl && !result.user.avatar ? { ...result.user, avatar: avatarUrl } : result.user; setUser(normalizeUser(user)); setToken(result.token); setStatus('authenticated'); } catch (err) { setError(err instanceof Error ? err : new Error(String(err))); setStatus('error'); } }, [client]); const logout = useCallback(async () => { try { await client.logout(); } catch { // Ignore logout errors — clear state regardless } setUser(null); setToken(null); setStatus('idle'); setError(null); pendingAuth.current = null; }, [client]); const restoreSession = useCallback(async (savedToken: string): Promise => { try { client.setToken(savedToken); const me = await client.getMe(); setUser(normalizeUser(me)); setToken(savedToken); setStatus('authenticated'); return true; } catch { client.setToken(null); setUser(null); setToken(null); setStatus('idle'); return false; } }, [client]); const refreshUser = useCallback(async () => { try { const me = await client.getMe(); setUser(normalizeUser(me)); } catch { // Silent failure — keep existing user state } }, [client]); // If shared context exists (inside AuthGate), prefer it over local state if (sharedAuth) return sharedAuth; return { status, user, token, isAuthenticated: status === 'authenticated', error, authenticate, register, logout, restoreSession, refreshUser, reset, }; }