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;
-