From a4fce709f0fa48183c0a43862f01327fae08b8a7 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sat, 4 Apr 2026 13:59:13 -0700 Subject: [PATCH] refactor: move web auth onto shared platform provider --- package.json | 1 + pnpm-lock.yaml | 6 ++ web/package.json | 1 + web/src/components/AuthContext.dom.test.tsx | 44 +++++----- web/src/components/AuthContext.test.ts | 12 ++- web/src/components/AuthContext.tsx | 83 ++++++++++++------- web/src/components/ComponentsSmoke.test.ts | 8 ++ web/src/components/Login.dom.test.tsx | 43 +++++----- web/src/components/Login.tsx | 40 ++++----- .../src/lib/tradingAuth.tsx | 11 +-- 10 files changed, 150 insertions(+), 99 deletions(-) rename shared/web-auth.tsx => web/src/lib/tradingAuth.tsx (81%) diff --git a/package.json b/package.json index d3342e1..ddc606a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@bytelyst/kill-switch-client": "link:../../learning_ai/learning_ai_common_plat/packages/kill-switch-client", + "@bytelyst/react-auth": "link:../../learning_ai/learning_ai_common_plat/packages/react-auth", "@bytelyst/react-native-platform-sdk": "link:../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk", "@bytelyst/telemetry-client": "link:../../learning_ai/learning_ai_common_plat/packages/telemetry-client" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c522416..0624c7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@bytelyst/kill-switch-client': specifier: link:../../learning_ai/learning_ai_common_plat/packages/kill-switch-client version: link:../../learning_ai/learning_ai_common_plat/packages/kill-switch-client + '@bytelyst/react-auth': + specifier: link:../../learning_ai/learning_ai_common_plat/packages/react-auth + version: link:../../learning_ai/learning_ai_common_plat/packages/react-auth '@bytelyst/react-native-platform-sdk': specifier: link:../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk version: link:../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk @@ -233,6 +236,9 @@ importers: '@bytelyst/kill-switch-client': specifier: link:../../../learning_ai/learning_ai_common_plat/packages/kill-switch-client version: link:../../../learning_ai/learning_ai_common_plat/packages/kill-switch-client + '@bytelyst/react-auth': + specifier: link:../../../learning_ai/learning_ai_common_plat/packages/react-auth + version: link:../../../learning_ai/learning_ai_common_plat/packages/react-auth '@bytelyst/telemetry-client': specifier: link:../../../learning_ai/learning_ai_common_plat/packages/telemetry-client version: link:../../../learning_ai/learning_ai_common_plat/packages/telemetry-client diff --git a/web/package.json b/web/package.json index a2cfd1a..a4ef896 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@bytelyst/kill-switch-client": "link:../../../learning_ai/learning_ai_common_plat/packages/kill-switch-client", + "@bytelyst/react-auth": "link:../../../learning_ai/learning_ai_common_plat/packages/react-auth", "@bytelyst/telemetry-client": "link:../../../learning_ai/learning_ai_common_plat/packages/telemetry-client", "@supabase/supabase-js": "^2.90.1", "lucide-react": "^0.562.0", diff --git a/web/src/components/AuthContext.dom.test.tsx b/web/src/components/AuthContext.dom.test.tsx index 8a3a0c7..6d2270d 100644 --- a/web/src/components/AuthContext.dom.test.tsx +++ b/web/src/components/AuthContext.dom.test.tsx @@ -7,29 +7,37 @@ import { tableNameProfiles, tableNameUsers } from '../lib/const'; const { getSessionMock, - onAuthStateChangeMock, signOutMock, fromMock, usersSingleMock, profilesLimitMock, profilesInsertMock, - unsubscribeMock + unsubscribeMock, + tradingAuthState } = vi.hoisted(() => ({ getSessionMock: vi.fn(), - onAuthStateChangeMock: vi.fn(), signOutMock: vi.fn(), fromMock: vi.fn(), usersSingleMock: vi.fn(), profilesLimitMock: vi.fn(), profilesInsertMock: vi.fn(), - unsubscribeMock: vi.fn() + unsubscribeMock: vi.fn(), + tradingAuthState: { + user: { id: 'user-1', email: 'sarah@example.com', role: 'admin', name: 'Sarah Algo' } as any, + isLoading: false, + logout: vi.fn() + } +})); + +vi.mock('../lib/tradingAuth', () => ({ + TradingAuthProvider: ({ children }: { children: React.ReactNode }) => children, + useTradingAuth: () => tradingAuthState })); vi.mock('../lib/supabaseClient', () => ({ supabase: { auth: { getSession: getSessionMock, - onAuthStateChange: onAuthStateChangeMock, signOut: signOutMock }, from: fromMock @@ -52,19 +60,15 @@ const Probe = () => { describe('AuthContext DOM behavior', () => { beforeEach(() => { getSessionMock.mockReset(); - onAuthStateChangeMock.mockReset(); signOutMock.mockReset(); fromMock.mockReset(); usersSingleMock.mockReset(); profilesLimitMock.mockReset(); profilesInsertMock.mockReset(); unsubscribeMock.mockReset(); - - onAuthStateChangeMock.mockReturnValue({ - data: { - subscription: { unsubscribe: unsubscribeMock } - } - }); + tradingAuthState.user = { id: 'user-1', email: 'sarah@example.com', role: 'admin', name: 'Sarah Algo' }; + tradingAuthState.isLoading = false; + tradingAuthState.logout.mockReset(); signOutMock.mockResolvedValue({ error: null }); usersSingleMock.mockResolvedValue({ @@ -115,7 +119,7 @@ describe('AuthContext DOM behavior', () => { }); const dispatchSpy = vi.spyOn(window, 'dispatchEvent'); - const { unmount } = render( + render( @@ -133,12 +137,10 @@ describe('AuthContext DOM behavior', () => { ]); expect(dispatchSpy).toHaveBeenCalled(); expect((dispatchSpy.mock.calls[0][0] as Event).type).toBe('profiles-updated'); - - unmount(); - expect(unsubscribeMock).toHaveBeenCalledTimes(1); }, 20000); it('handles no initial session gracefully', async () => { + tradingAuthState.user = null; getSessionMock.mockResolvedValue({ data: { session: null } }); render(); @@ -151,15 +153,14 @@ describe('AuthContext DOM behavior', () => { it('handles auth state changes with no session', async () => { getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } }); - render(); + const { rerender } = render(); await waitFor(() => { expect(screen.getByTestId('user')).toHaveTextContent('u1'); }); - const authChangeCb = onAuthStateChangeMock.mock.calls[0][0]; - // Simulate sign out event - authChangeCb('SIGNED_OUT', null); + tradingAuthState.user = null; + rerender(); await waitFor(() => { expect(screen.getByTestId('user')).toHaveTextContent('none'); @@ -169,6 +170,7 @@ describe('AuthContext DOM behavior', () => { it('logs error when profile fetch fails', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); + tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' }; getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } }); usersSingleMock.mockResolvedValue({ data: null, error: { message: 'Profile Not Found' } }); @@ -182,6 +184,7 @@ describe('AuthContext DOM behavior', () => { it('handles unexpected errors in fetchProfile', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); + tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' }; getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } }); usersSingleMock.mockImplementation(() => { throw new Error('Crashed'); }); @@ -195,6 +198,7 @@ describe('AuthContext DOM behavior', () => { it('handles unexpected errors in ensureDefaultProfile', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); + tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' }; getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } }); profilesLimitMock.mockImplementation(() => { throw new Error('Limit Error'); }); diff --git a/web/src/components/AuthContext.test.ts b/web/src/components/AuthContext.test.ts index 41ee440..61888b7 100644 --- a/web/src/components/AuthContext.test.ts +++ b/web/src/components/AuthContext.test.ts @@ -8,6 +8,15 @@ import { useAuth } from './AuthContext'; +vi.mock('../lib/tradingAuth', () => ({ + TradingAuthProvider: ({ children }: { children: React.ReactNode }) => children, + useTradingAuth: () => ({ + user: null, + isLoading: false, + logout: vi.fn(), + }) +})); + vi.mock('../lib/supabaseClient', () => { const query: any = { select: vi.fn(() => query), @@ -21,9 +30,6 @@ vi.mock('../lib/supabaseClient', () => { supabase: { auth: { getSession: vi.fn(async () => ({ data: { session: null } })), - onAuthStateChange: vi.fn(() => ({ - data: { subscription: { unsubscribe: vi.fn() } } - })), signOut: vi.fn(async () => ({ error: null })) }, from: vi.fn(() => query) diff --git a/web/src/components/AuthContext.tsx b/web/src/components/AuthContext.tsx index 4f7c1a3..5c145de 100644 --- a/web/src/components/AuthContext.tsx +++ b/web/src/components/AuthContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import type { User, Session } from '@supabase/supabase-js'; import { supabase } from '../lib/supabaseClient'; import { tableNameUsers, tableNameProfiles } from '../lib/const'; +import { TradingAuthProvider, useTradingAuth } from '../lib/tradingAuth'; // Define the shape of our extended user profile export interface UserProfile { @@ -75,39 +76,49 @@ export const buildDefaultProfilePayload = (userId: string) => ({ }); export function AuthProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function AuthBridge({ children }: { children: React.ReactNode }) { + const tradingAuth = useTradingAuth(); const [session, setSession] = useState(null); const [user, setUser] = useState(null); const [profile, setProfile] = useState(null); - const [loading, setLoading] = useState(true); + const [profileLoading, setProfileLoading] = useState(true); useEffect(() => { - // 1. Get initial session - supabase.auth.getSession().then(({ data: { session } }) => { - setSession((session as Session | null) ?? null); - setUser((session?.user as User | null) ?? null); - if (session?.user) { - fetchProfile(session.user.id); - } else { - setLoading(false); - } - }); - - // 2. Listen for changes - const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { - setSession((session as Session | null) ?? null); - setUser((session?.user as User | null) ?? null); - if (session?.user) { - fetchProfile(session.user.id); - } else { + let active = true; + const syncSession = async () => { + if (!tradingAuth.user?.id) { + if (!active) return; + setSession(null); + setUser(null); setProfile(null); - setLoading(false); + setProfileLoading(false); + return; } - }); - return () => subscription.unsubscribe(); - }, []); + const { data: { session: nextSession } } = await supabase.auth.getSession(); + if (!active) return; + const normalizedSession = (nextSession as Session | null) ?? null; + const normalizedUser = (normalizedSession?.user as User | null) ?? buildFallbackAuthUser(tradingAuth.user); + setSession(normalizedSession); + setUser(normalizedUser); + await fetchProfile(tradingAuth.user.id, normalizedUser); + }; - const fetchProfile = async (userId: string) => { + void syncSession(); + + return () => { + active = false; + }; + }, [tradingAuth.user?.id]); + + const fetchProfile = async (userId: string, authUserOverride?: User | null) => { try { const { data, error } = await supabase .from(tableNameUsers) @@ -117,7 +128,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (error) { console.error('Error fetching user profile:', error); - setProfile(buildFallbackProfile(user)); + setProfile(buildFallbackProfile(authUserOverride ?? user)); ensureDefaultProfile(userId); } else { setProfile(data as UserProfile); @@ -126,9 +137,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } } catch (err) { console.error('Unexpected error fetching profile:', err); - setProfile(buildFallbackProfile(user)); + setProfile(buildFallbackProfile(authUserOverride ?? user)); } finally { - setLoading(false); + setProfileLoading(false); } }; @@ -152,6 +163,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const signOut = async () => { await supabase.auth.signOut(); + tradingAuth.logout(); setSession(null); setUser(null); setProfile(null); @@ -167,7 +179,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { session, user, profile, - loading, + loading: tradingAuth.isLoading || profileLoading, signOut, refreshProfile }; @@ -175,6 +187,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return {children}; } +const buildFallbackAuthUser = (authUser: { id: string; email?: string; role?: string; name?: string; } | null): User | null => { + if (!authUser?.id) return null; + return { + id: authUser.id, + email: authUser.email || '', + aud: 'authenticated', + app_metadata: {}, + user_metadata: { + role: authUser.role || 'member', + displayName: authUser.name || authUser.email || '', + }, + created_at: new Date(0).toISOString(), + } as User; +}; + export const useAuth = () => { const context = useContext(AuthContext); if (context === undefined) { diff --git a/web/src/components/ComponentsSmoke.test.ts b/web/src/components/ComponentsSmoke.test.ts index 9cc9370..bf0e62f 100644 --- a/web/src/components/ComponentsSmoke.test.ts +++ b/web/src/components/ComponentsSmoke.test.ts @@ -18,6 +18,14 @@ vi.mock('../components/AuthContext', () => ({ }) })); +vi.mock('../lib/tradingAuth', () => ({ + useTradingAuth: () => ({ + login: vi.fn(async () => true), + register: vi.fn(async () => true), + error: null, + }) +})); + vi.mock('../lib/supabaseClient', () => { const query: any = { select: vi.fn(() => query), diff --git a/web/src/components/Login.dom.test.tsx b/web/src/components/Login.dom.test.tsx index 09a7143..fca2e73 100644 --- a/web/src/components/Login.dom.test.tsx +++ b/web/src/components/Login.dom.test.tsx @@ -4,17 +4,26 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Login } from './Login'; -const { signInWithPasswordMock, signUpMock, resetPasswordForEmailMock } = vi.hoisted(() => ({ - signInWithPasswordMock: vi.fn(), - signUpMock: vi.fn(), - resetPasswordForEmailMock: vi.fn() +const { loginMock, registerMock, resetPasswordForEmailMock, tradingAuthState } = vi.hoisted(() => ({ + loginMock: vi.fn(), + registerMock: vi.fn(), + resetPasswordForEmailMock: vi.fn(), + tradingAuthState: { + error: null as string | null + } +})); + +vi.mock('../lib/tradingAuth', () => ({ + useTradingAuth: () => ({ + login: loginMock, + register: registerMock, + error: tradingAuthState.error, + }) })); vi.mock('../lib/supabaseClient', () => ({ supabase: { auth: { - signInWithPassword: signInWithPasswordMock, - signUp: signUpMock, resetPasswordForEmail: resetPasswordForEmailMock } } @@ -22,16 +31,18 @@ vi.mock('../lib/supabaseClient', () => ({ describe('Login DOM flow', () => { beforeEach(() => { - signInWithPasswordMock.mockReset(); - signUpMock.mockReset(); + loginMock.mockReset(); + registerMock.mockReset(); resetPasswordForEmailMock.mockReset(); - signInWithPasswordMock.mockResolvedValue({ error: null }); - signUpMock.mockResolvedValue({ error: null }); + tradingAuthState.error = null; + loginMock.mockResolvedValue(true); + registerMock.mockResolvedValue(true); resetPasswordForEmailMock.mockResolvedValue({ error: null }); }); it('submits sign-in credentials and surfaces auth errors', async () => { - signInWithPasswordMock.mockResolvedValueOnce({ error: { message: 'Invalid login credentials' } }); + tradingAuthState.error = 'Invalid login credentials'; + loginMock.mockResolvedValueOnce(false); const user = userEvent.setup(); render(); @@ -41,10 +52,7 @@ describe('Login DOM flow', () => { await user.click(screen.getByRole('button', { name: 'Sign In' })); await waitFor(() => { - expect(signInWithPasswordMock).toHaveBeenCalledWith({ - email: 'user@demo.com', - password: 'bad-password' - }); + expect(loginMock).toHaveBeenCalledWith('user@demo.com', 'bad-password'); expect(screen.getByText('Invalid login credentials')).toBeInTheDocument(); }); @@ -65,10 +73,7 @@ describe('Login DOM flow', () => { await user.click(screen.getByRole('button', { name: 'Sign Up' })); await waitFor(() => { - expect(signUpMock).toHaveBeenCalledWith({ - email: 'new@demo.com', - password: 'StrongPassword1!' - }); + expect(registerMock).toHaveBeenCalledWith('new@demo.com', 'StrongPassword1!', 'new'); expect(screen.getByText('Check your email for the confirmation link!')).toBeInTheDocument(); }); }); diff --git a/web/src/components/Login.tsx b/web/src/components/Login.tsx index dfdaef2..36b07e0 100644 --- a/web/src/components/Login.tsx +++ b/web/src/components/Login.tsx @@ -1,9 +1,11 @@ -import React, { useState } from 'react'; -import { supabase } from '../lib/supabaseClient'; - -export function Login() { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); +import React, { useState } from 'react'; +import { supabase } from '../lib/supabaseClient'; +import { useTradingAuth } from '../lib/tradingAuth'; + +export function Login() { + const tradingAuth = useTradingAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [isSignUp, setIsSignUp] = useState(false); @@ -23,22 +25,16 @@ export function Login() { }); if (error) throw error; setMessage('Password reset link sent! Check your email.'); - } else if (isSignUp) { - const { error } = await supabase.auth.signUp({ - email, - password, - }); - if (error) throw error; - setMessage('Check your email for the confirmation link!'); - } else { - const { error } = await supabase.auth.signInWithPassword({ - email, - password, - }); - if (error) throw error; - } - } catch (err: any) { - setError(err.message); + } else if (isSignUp) { + const ok = await tradingAuth.register(email, password, email.split('@')[0] || 'Trader'); + if (!ok) throw new Error(tradingAuth.error || 'Registration failed'); + setMessage('Check your email for the confirmation link!'); + } else { + const ok = await tradingAuth.login(email, password); + if (!ok) throw new Error(tradingAuth.error || 'Login failed'); + } + } catch (err: any) { + setError(err.message); } finally { setLoading(false); } diff --git a/shared/web-auth.tsx b/web/src/lib/tradingAuth.tsx similarity index 81% rename from shared/web-auth.tsx rename to web/src/lib/tradingAuth.tsx index 938767d..854bfbb 100644 --- a/shared/web-auth.tsx +++ b/web/src/lib/tradingAuth.tsx @@ -1,16 +1,14 @@ import { createAuthProvider, type BaseUser } from '@bytelyst/react-auth'; -import { getRuntimeEnvironment } from './runtime.js'; +import { tradingRuntime } from './runtime'; export interface TradingAuthUser extends BaseUser { id: string; plan?: string; } -const runtime = getRuntimeEnvironment('web'); - export const tradingWebAuth = createAuthProvider({ - baseUrl: runtime.platformApiUrl, - productId: runtime.productId, + baseUrl: tradingRuntime.platformApiUrl, + productId: tradingRuntime.productId, storagePrefix: 'invttrdg_web', loginEndpoint: '/auth/login', registerEndpoint: '/auth/register', @@ -18,7 +16,7 @@ export const tradingWebAuth = createAuthProvider({ changePasswordEndpoint: '/auth/change-password', deleteAccountEndpoint: '/auth/account', refreshEndpoint: '/auth/refresh', - mapLoginResponse: data => { + mapLoginResponse: (data: unknown) => { const response = data as { user: TradingAuthUser; accessToken: string; @@ -34,4 +32,3 @@ export const tradingWebAuth = createAuthProvider({ export const TradingAuthProvider = tradingWebAuth.AuthProvider; export const useTradingAuth = tradingWebAuth.useAuth; -