@urbackend/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ import React, { createContext, useContext, useEffect, useState, useMemo } from 'react';
2
+ import { UrBackendClient, AuthModule, DatabaseModule, StorageModule } from '@urbackend/sdk';
3
+ import type { AuthUser } from '@urbackend/sdk';
4
+
5
+ interface UrContextValue {
6
+ client: UrBackendClient | null;
7
+ auth: AuthModule | null;
8
+ db: DatabaseModule | null;
9
+ storage: StorageModule | null;
10
+ user: AuthUser | null;
11
+ setUser: React.Dispatch<React.SetStateAction<AuthUser | null>>;
12
+ isInitializing: boolean;
13
+ isLoading: boolean;
14
+ setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
15
+ error: string | null;
16
+ setError: React.Dispatch<React.SetStateAction<string | null>>;
17
+ }
18
+
19
+ const UrContext = createContext<UrContextValue | undefined>(undefined);
20
+
21
+ export interface UrProviderProps {
22
+ apiKey: string;
23
+ baseUrl?: string;
24
+ children: React.ReactNode;
25
+ }
26
+
27
+ export const UrProvider: React.FC<UrProviderProps> = ({ apiKey, baseUrl, children }) => {
28
+ const [user, setUser] = useState<AuthUser | null>(null);
29
+ const [isInitializing, setIsInitializing] = useState(true);
30
+ const [isLoading, setIsLoading] = useState(false);
31
+ const [error, setError] = useState<string | null>(null);
32
+
33
+ const { client, auth, db, storage } = useMemo(() => {
34
+ const _client = new UrBackendClient({ apiKey, baseUrl });
35
+ return {
36
+ client: _client,
37
+ auth: new AuthModule(_client),
38
+ db: new DatabaseModule(_client),
39
+ storage: new StorageModule(_client),
40
+ };
41
+ }, [apiKey, baseUrl]);
42
+
43
+ useEffect(() => {
44
+ let mounted = true;
45
+
46
+ const initAuth = async () => {
47
+ try {
48
+ // Hydrate from localStorage first as a fallback for environments without cookies
49
+ if (typeof window !== 'undefined') {
50
+ const savedToken = localStorage.getItem('ur_auth_token');
51
+ if (savedToken) auth.setToken(savedToken);
52
+ }
53
+
54
+ // Check for social auth callback params
55
+ const urlParams = new URLSearchParams(window.location.search);
56
+ const hashParams = new URLSearchParams(window.location.hash.substring(1));
57
+ const token = hashParams.get('token');
58
+ const rtCode = urlParams.get('rtCode');
59
+ const error = urlParams.get('error');
60
+
61
+ if (error) {
62
+ console.error('Social Auth Error:', error);
63
+ if (mounted) setError(error);
64
+ window.history.replaceState({}, document.title, window.location.pathname);
65
+ } else if (token) {
66
+ // Social auth succeeded, establish session immediately
67
+ auth.setToken(token);
68
+ if (typeof window !== 'undefined') localStorage.setItem('ur_auth_token', token);
69
+
70
+ if (rtCode) {
71
+ // Exchange for long-lived refresh token
72
+ try {
73
+ const exRes = await auth.socialExchange({ token, rtCode });
74
+ const exToken = (exRes as any).accessToken || (exRes as any).token;
75
+ if (exToken && typeof window !== 'undefined') localStorage.setItem('ur_auth_token', exToken);
76
+ } catch (err: any) {
77
+ console.error('Failed to exchange refresh token', err);
78
+ if (mounted) setError(err.message || 'Failed to complete social login');
79
+ throw err;
80
+ }
81
+ }
82
+ window.history.replaceState({}, document.title, window.location.pathname);
83
+ } else {
84
+ // Attempt to silently refresh session using the HTTP-only cookie
85
+ try {
86
+ const res = await auth.refreshToken();
87
+ const newToken = res.accessToken || (res as any).token;
88
+ if (newToken && typeof window !== 'undefined') localStorage.setItem('ur_auth_token', newToken);
89
+ } catch (e) {
90
+ // If refresh fails, me() will catch it
91
+ }
92
+ }
93
+
94
+ const currentUser = await auth.me();
95
+ if (mounted) {
96
+ setUser(currentUser);
97
+ }
98
+ } catch (error: any) {
99
+ if (mounted) {
100
+ setUser(null);
101
+ // Don't set global error for initial me() check failure (usually just means not logged in)
102
+ }
103
+ } finally {
104
+ if (mounted) {
105
+ setIsInitializing(false);
106
+ }
107
+ }
108
+ };
109
+
110
+ initAuth();
111
+
112
+ return () => {
113
+ mounted = false;
114
+ };
115
+ }, [auth]);
116
+
117
+ const value: UrContextValue = {
118
+ client,
119
+ auth,
120
+ db,
121
+ storage,
122
+ user,
123
+ setUser,
124
+ isInitializing,
125
+ isLoading,
126
+ setIsLoading,
127
+ error,
128
+ setError,
129
+ };
130
+
131
+ return <UrContext.Provider value={value}>{children}</UrContext.Provider>;
132
+ };
133
+
134
+ export const useUrContext = () => {
135
+ const context = useContext(UrContext);
136
+ if (!context) {
137
+ throw new Error('useUrContext must be used within an UrProvider');
138
+ }
139
+ return context;
140
+ };
package/src/hooks.ts ADDED
@@ -0,0 +1,163 @@
1
+ import { useCallback } from 'react';
2
+ import { useUrContext } from './context';
3
+ import type {
4
+ LoginPayload,
5
+ SignUpPayload,
6
+ ChangePasswordPayload,
7
+ VerifyEmailPayload,
8
+ RequestPasswordResetPayload,
9
+ ResetPasswordPayload
10
+ } from '@urbackend/sdk';
11
+
12
+ export const useAuth = () => {
13
+ const { auth, user, setUser, isInitializing, isLoading, setIsLoading, error, setError } = useUrContext();
14
+
15
+ if (!auth) {
16
+ throw new Error('Auth module not initialized. Make sure you are inside UrProvider.');
17
+ }
18
+
19
+ const login = useCallback(async (payload: LoginPayload) => {
20
+ try {
21
+ setError(null);
22
+ setIsLoading(true);
23
+ const res = await auth.login(payload);
24
+ const token = res.accessToken || (res as any).token;
25
+ if (token && typeof window !== 'undefined') localStorage.setItem('ur_auth_token', token);
26
+ const currentUser = await auth.me();
27
+ setUser(currentUser);
28
+ } catch (err: any) {
29
+ setError(err.message || 'Login failed');
30
+ throw err;
31
+ } finally {
32
+ setIsLoading(false);
33
+ }
34
+ }, [auth, setUser, setIsLoading, setError]);
35
+
36
+ const signUp = useCallback(async (payload: SignUpPayload) => {
37
+ try {
38
+ setError(null);
39
+ setIsLoading(true);
40
+ const newUser = await auth.signUp(payload);
41
+ return newUser;
42
+ } catch (err: any) {
43
+ setError(err.message || 'Sign up failed');
44
+ throw err;
45
+ } finally {
46
+ setIsLoading(false);
47
+ }
48
+ }, [auth, setIsLoading, setError]);
49
+
50
+ const logout = useCallback(async () => {
51
+ try {
52
+ setError(null);
53
+ setIsLoading(true);
54
+ await auth.logout();
55
+ if (typeof window !== 'undefined') localStorage.removeItem('ur_auth_token');
56
+ setUser(null);
57
+ } catch (err: any) {
58
+ setError(err.message || 'Logout failed');
59
+ throw err;
60
+ } finally {
61
+ setIsLoading(false);
62
+ }
63
+ }, [auth, setUser, setIsLoading, setError]);
64
+
65
+ const socialLogin = useCallback((provider: 'google' | 'github') => {
66
+ setError(null);
67
+ const url = auth.socialStart(provider);
68
+ window.location.href = url;
69
+ }, [auth, setError]);
70
+
71
+ const verifyEmail = useCallback(async (payload: VerifyEmailPayload) => {
72
+ try {
73
+ setError(null);
74
+ return await auth.verifyEmail(payload);
75
+ } catch (err: any) {
76
+ setError(err.message || 'Email verification failed');
77
+ throw err;
78
+ }
79
+ }, [auth, setError]);
80
+
81
+ const changePassword = useCallback(async (payload: ChangePasswordPayload) => {
82
+ try {
83
+ setError(null);
84
+ return await auth.changePassword(payload);
85
+ } catch (err: any) {
86
+ setError(err.message || 'Failed to change password');
87
+ throw err;
88
+ }
89
+ }, [auth, setError]);
90
+
91
+ const requestPasswordReset = useCallback(async (payload: RequestPasswordResetPayload) => {
92
+ try {
93
+ setError(null);
94
+ setIsLoading(true);
95
+ return await auth.requestPasswordReset(payload);
96
+ } catch (err: any) {
97
+ setError(err.message || 'Failed to request password reset');
98
+ throw err;
99
+ } finally {
100
+ setIsLoading(false);
101
+ }
102
+ }, [auth, setError, setIsLoading]);
103
+
104
+ const resetPassword = useCallback(async (payload: ResetPasswordPayload) => {
105
+ try {
106
+ setError(null);
107
+ setIsLoading(true);
108
+ return await auth.resetPassword(payload);
109
+ } catch (err: any) {
110
+ setError(err.message || 'Failed to reset password');
111
+ throw err;
112
+ } finally {
113
+ setIsLoading(false);
114
+ }
115
+ }, [auth, setError, setIsLoading]);
116
+
117
+ const clearError = useCallback(() => setError(null), [setError]);
118
+
119
+ return {
120
+ user,
121
+ isInitializing,
122
+ isLoading,
123
+ error,
124
+ isAuthenticated: !!user,
125
+ login,
126
+ signUp,
127
+ logout,
128
+ socialLogin,
129
+ verifyEmail,
130
+ changePassword,
131
+ requestPasswordReset,
132
+ resetPassword,
133
+ clearError,
134
+ authApi: auth // Escape hatch to underlying SDK
135
+ };
136
+ };
137
+
138
+ export const useUser = () => {
139
+ const { user, isInitializing, isLoading, error } = useUrContext();
140
+ return {
141
+ user,
142
+ isInitializing,
143
+ isLoading,
144
+ error,
145
+ isAuthenticated: !!user,
146
+ };
147
+ };
148
+
149
+ export const useDb = () => {
150
+ const { db } = useUrContext();
151
+ if (!db) {
152
+ throw new Error('Database module not initialized.');
153
+ }
154
+ return db;
155
+ };
156
+
157
+ export const useStorage = () => {
158
+ const { storage } = useUrContext();
159
+ if (!storage) {
160
+ throw new Error('Storage module not initialized.');
161
+ }
162
+ return storage;
163
+ };
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { UrProvider, useUrContext } from './context';
2
+ export type { UrProviderProps } from './context';
3
+
4
+ export { useAuth, useUser, useDb, useStorage } from './hooks';
5
+ export { ProtectedRoute, GuestRoute } from './components';
6
+ export type { ProtectedRouteProps, GuestRouteProps } from './components';
7
+
8
+ export { UrAuth } from './components/UrAuth';
9
+ export type { UrAuthProps } from './components/UrAuth';
10
+
11
+ export * from './components/UrUserButton';
12
+
13
+ export * from '@urbackend/sdk'; // re-export types so users don't need to import from sdk directly
@@ -0,0 +1,90 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { UrAuth } from '../src/components/UrAuth';
5
+
6
+ // Mock the hooks module
7
+ const mockLogin = vi.fn();
8
+ const mockSignUp = vi.fn();
9
+ const mockSocialLogin = vi.fn();
10
+ const mockRequestPasswordReset = vi.fn();
11
+ const mockResetPassword = vi.fn();
12
+ const mockClearError = vi.fn();
13
+
14
+ vi.mock('../src/hooks', () => ({
15
+ useAuth: () => ({
16
+ login: mockLogin,
17
+ signUp: mockSignUp,
18
+ socialLogin: mockSocialLogin,
19
+ requestPasswordReset: mockRequestPasswordReset,
20
+ resetPassword: mockResetPassword,
21
+ isLoading: false,
22
+ error: null,
23
+ clearError: mockClearError,
24
+ }),
25
+ }));
26
+
27
+ describe('UrAuth Component', () => {
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ });
31
+
32
+ it('renders login form by default', () => {
33
+ render(<UrAuth />);
34
+
35
+ expect(screen.getByPlaceholderText('Enter your email address')).toBeInTheDocument();
36
+ expect(screen.getByPlaceholderText('Enter your password')).toBeInTheDocument();
37
+ expect(screen.getByRole('button', { name: 'Log In' })).toBeInTheDocument();
38
+ });
39
+
40
+ it('switches to signup form', () => {
41
+ render(<UrAuth />);
42
+
43
+ const signupToggle = screen.getAllByText('Sign Up')[0]; // Top switcher
44
+ fireEvent.click(signupToggle);
45
+
46
+ expect(screen.getByPlaceholderText('Enter your name')).toBeInTheDocument();
47
+ expect(screen.getByRole('button', { name: 'Create Account' })).toBeInTheDocument();
48
+ });
49
+
50
+ it('calls login on submit', async () => {
51
+ mockLogin.mockResolvedValueOnce(undefined);
52
+ render(<UrAuth onSuccess={() => {}} />);
53
+
54
+ fireEvent.change(screen.getByPlaceholderText('Enter your email address'), { target: { value: 'test@example.com' } });
55
+ fireEvent.change(screen.getByPlaceholderText('Enter your password'), { target: { value: 'password123' } });
56
+
57
+ fireEvent.click(screen.getByRole('button', { name: 'Log In' }));
58
+
59
+ await waitFor(() => {
60
+ expect(mockLogin).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123' });
61
+ });
62
+ });
63
+
64
+ it('calls socialLogin when a provider button is clicked', () => {
65
+ render(<UrAuth providers={['google', 'github']} />);
66
+
67
+ fireEvent.click(screen.getByText('Continue with Google'));
68
+ expect(mockSocialLogin).toHaveBeenCalledWith('google');
69
+
70
+ fireEvent.click(screen.getByText('Continue with GitHub'));
71
+ expect(mockSocialLogin).toHaveBeenCalledWith('github');
72
+ });
73
+
74
+ it('switches to forgot password flow', async () => {
75
+ render(<UrAuth />);
76
+
77
+ fireEvent.click(screen.getByText('Forgot password?'));
78
+
79
+ expect(screen.getByText('Reset Password')).toBeInTheDocument();
80
+ expect(screen.getByRole('button', { name: 'Send Reset Code' })).toBeInTheDocument();
81
+
82
+ mockRequestPasswordReset.mockResolvedValueOnce(undefined);
83
+ fireEvent.change(screen.getByPlaceholderText('Enter your email address'), { target: { value: 'test@example.com' } });
84
+ fireEvent.click(screen.getByRole('button', { name: 'Send Reset Code' }));
85
+
86
+ await waitFor(() => {
87
+ expect(mockRequestPasswordReset).toHaveBeenCalledWith({ email: 'test@example.com' });
88
+ });
89
+ });
90
+ });
@@ -0,0 +1,113 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { UrProvider, useUrContext } from '../src/context';
5
+ import { AuthModule } from '@urbackend/sdk';
6
+
7
+ // Mock the SDK modules
8
+ vi.mock('@urbackend/sdk', () => {
9
+ const MockAuthModule = vi.fn().mockImplementation(() => ({
10
+ setToken: vi.fn(),
11
+ refreshToken: vi.fn().mockResolvedValue(undefined),
12
+ me: vi.fn().mockResolvedValue({ id: 'user123', email: 'test@example.com' }),
13
+ socialExchange: vi.fn().mockResolvedValue({ refreshToken: 'fake-rt' }),
14
+ }));
15
+
16
+ return {
17
+ UrBackendClient: vi.fn(),
18
+ AuthModule: MockAuthModule,
19
+ DatabaseModule: vi.fn(),
20
+ StorageModule: vi.fn(),
21
+ };
22
+ });
23
+
24
+ describe('UrProvider & useUrContext', () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ it('throws an error if useUrContext is used outside UrProvider', () => {
30
+ const TestComponent = () => {
31
+ useUrContext();
32
+ return <div>Test</div>;
33
+ };
34
+
35
+ // React Error Boundary catch
36
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
37
+
38
+ expect(() => render(<TestComponent />)).toThrow('useUrContext must be used within an UrProvider');
39
+
40
+ consoleError.mockRestore();
41
+ });
42
+
43
+ it('initializes context and fetches user', async () => {
44
+ const TestComponent = () => {
45
+ const { user, isInitializing } = useUrContext();
46
+ if (isInitializing) return <div>Loading...</div>;
47
+ return <div>User: {user?.email}</div>;
48
+ };
49
+
50
+ render(
51
+ <UrProvider apiKey="test-key" baseUrl="http://localhost:3000">
52
+ <TestComponent />
53
+ </UrProvider>
54
+ );
55
+
56
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
57
+
58
+ await waitFor(() => {
59
+ expect(screen.getByText('User: test@example.com')).toBeInTheDocument();
60
+ });
61
+ });
62
+
63
+ it('exchanges social token and rtCode if present in URL', async () => {
64
+ // Mock window.location
65
+ const originalLocation = window.location;
66
+ // @ts-ignore
67
+ delete window.location;
68
+ window.location = {
69
+ ...originalLocation,
70
+ search: '?rtCode=test-rt-code',
71
+ hash: '#token=test-temp-token',
72
+ pathname: '/auth/callback',
73
+ } as any;
74
+
75
+ const originalHistory = window.history;
76
+ // @ts-ignore
77
+ delete window.history;
78
+ window.history = {
79
+ ...originalHistory,
80
+ replaceState: vi.fn(),
81
+ } as any;
82
+
83
+ const TestComponent = () => {
84
+ const { isInitializing } = useUrContext();
85
+ if (isInitializing) return <div>Loading...</div>;
86
+ return <div>Ready</div>;
87
+ };
88
+
89
+ render(
90
+ <UrProvider apiKey="test-key" baseUrl="http://localhost:3000">
91
+ <TestComponent />
92
+ </UrProvider>
93
+ );
94
+
95
+ await waitFor(() => {
96
+ expect(screen.getByText('Ready')).toBeInTheDocument();
97
+ });
98
+
99
+ // We can't access the specific instance directly, but we can check if the methods on the mock prototype were called
100
+ // Since AuthModule is mocked to return the object above, we can check its methods.
101
+ // However, vitest module mocking returns the factory. Let's just verify it using a spy on a global or check the logic.
102
+ // Actually, since we redefined AuthModule mock above, we need to inspect the instances.
103
+ const mockAuthInstance = vi.mocked(AuthModule).mock.results[0]?.value;
104
+ if (mockAuthInstance) {
105
+ expect(mockAuthInstance.setToken).toHaveBeenCalledWith('test-temp-token');
106
+ expect(mockAuthInstance.socialExchange).toHaveBeenCalledWith({ token: 'test-temp-token', rtCode: 'test-rt-code' });
107
+ }
108
+
109
+ // Restore window.location and window.history
110
+ window.location = originalLocation;
111
+ window.history = originalHistory;
112
+ });
113
+ });
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom';
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "jsx": "react-jsx",
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "noFallthroughCasesInSwitch": true
22
+ },
23
+ "include": ["src"]
24
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['cjs', 'esm'],
6
+ dts: true,
7
+ splitting: false,
8
+ sourcemap: true,
9
+ clean: true,
10
+ external: ['react', 'react-dom'],
11
+ });
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'jsdom',
6
+ globals: true,
7
+ setupFiles: ['./tests/setupTests.ts'],
8
+ },
9
+ });