diff --git a/src/App.tsx b/src/App.tsx index 0a06a9648..54bb79888 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Provider } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; import { store, persistor } from './store'; import SocketProvider from './providers/SocketProvider'; +import UserProvider from './providers/UserProvider'; import AppRoutes from './AppRoutes'; function App() { @@ -10,9 +11,11 @@ function App() { - - - + + + + + diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts new file mode 100644 index 000000000..48bf54640 --- /dev/null +++ b/src/hooks/useUser.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import { useAppSelector, useAppDispatch } from '../store/hooks'; +import { fetchCurrentUser } from '../store/userSlice'; + +/** + * Hook to access user data and automatically fetch it when token is available + */ +export const useUser = () => { + const dispatch = useAppDispatch(); + const token = useAppSelector((state) => state.auth.token); + const user = useAppSelector((state) => state.user.user); + const isLoading = useAppSelector((state) => state.user.isLoading); + const error = useAppSelector((state) => state.user.error); + + useEffect(() => { + // Fetch user data when token is available and user is not loaded + if (token && !user && !isLoading) { + dispatch(fetchCurrentUser()); + } + }, [token, user, isLoading, dispatch]); + + return { + user, + isLoading, + error, + refetch: () => dispatch(fetchCurrentUser()), + }; +}; diff --git a/src/providers/UserProvider.tsx b/src/providers/UserProvider.tsx new file mode 100644 index 000000000..02eace3b7 --- /dev/null +++ b/src/providers/UserProvider.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import { useAppSelector, useAppDispatch } from '../store/hooks'; +import { fetchCurrentUser } from '../store/userSlice'; + +/** + * UserProvider automatically fetches user data when JWT token is available + */ +const UserProvider = ({ children }: { children: React.ReactNode }) => { + const dispatch = useAppDispatch(); + const token = useAppSelector((state) => state.auth.token); + const user = useAppSelector((state) => state.user.user); + const isLoading = useAppSelector((state) => state.user.isLoading); + + useEffect(() => { + // Fetch user data when token is available and user is not loaded + if (token && !user && !isLoading) { + dispatch(fetchCurrentUser()); + } + }, [token, user, isLoading, dispatch]); + + return <>{children}; +}; + +export default UserProvider; diff --git a/src/services/api/userApi.ts b/src/services/api/userApi.ts new file mode 100644 index 000000000..19e8d332e --- /dev/null +++ b/src/services/api/userApi.ts @@ -0,0 +1,19 @@ +import { apiClient } from '../apiClient'; +import type { GetMeResponse, User } from '../../types/api'; + +/** + * User API endpoints + */ +export const userApi = { + /** + * Get current authenticated user information + * GET /telegram/me + */ + getMe: async (): Promise => { + const response = await apiClient.get('/telegram/me'); + if (!response.success) { + throw new Error(response.error || 'Failed to fetch user data'); + } + return response.data; + }, +}; diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts new file mode 100644 index 000000000..7ddc17f41 --- /dev/null +++ b/src/services/apiClient.ts @@ -0,0 +1,159 @@ +import { BACKEND_URL } from '../utils/config'; +import type { ApiResponse, ApiError } from '../types/api'; +import { store } from '../store'; + +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +interface RequestOptions { + method?: HttpMethod; + body?: unknown; + headers?: Record; + requireAuth?: boolean; +} + +/** + * API Client for making requests to the backend + * Handles authentication, error handling, and response typing + */ +class ApiClient { + private baseUrl: string; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } + + /** + * Get the current JWT token from Redux store + */ + private getToken(): string | null { + return store.getState().auth.token; + } + + /** + * Build headers for the request + */ + private buildHeaders(options: RequestOptions): HeadersInit { + const headers: Record = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + // Add authorization header if auth is required + if (options.requireAuth !== false) { + const token = this.getToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + } + + return headers; + } + + /** + * Make an API request + */ + private async request( + endpoint: string, + options: RequestOptions = {} + ): Promise { + const { method = 'GET', body, requireAuth = true } = options; + + const url = `${this.baseUrl}${endpoint}`; + const headers = this.buildHeaders({ ...options, requireAuth }); + + const config: RequestInit = { + method, + headers, + }; + + if (body && method !== 'GET') { + config.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, config); + + // Handle non-JSON responses + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return {} as T; + } + + const data = await response.json(); + + // Handle error responses + if (!response.ok) { + const error: ApiError = data.error + ? { success: false, error: data.error, message: data.message } + : { success: false, error: `HTTP ${response.status}: ${response.statusText}` }; + throw error; + } + + return data as T; + } catch (error) { + // Re-throw API errors as-is + if (error && typeof error === 'object' && 'error' in error) { + throw error; + } + + // Wrap network/other errors + throw { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + } as ApiError; + } + } + + /** + * GET request + */ + async get(endpoint: string, options?: Omit): Promise { + return this.request(endpoint, { ...options, method: 'GET' }); + } + + /** + * POST request + */ + async post( + endpoint: string, + body?: unknown, + options?: Omit + ): Promise { + return this.request(endpoint, { ...options, method: 'POST', body }); + } + + /** + * PUT request + */ + async put( + endpoint: string, + body?: unknown, + options?: Omit + ): Promise { + return this.request(endpoint, { ...options, method: 'PUT', body }); + } + + /** + * PATCH request + */ + async patch( + endpoint: string, + body?: unknown, + options?: Omit + ): Promise { + return this.request(endpoint, { ...options, method: 'PATCH', body }); + } + + /** + * DELETE request + */ + async delete(endpoint: string, options?: Omit): Promise { + return this.request(endpoint, { ...options, method: 'DELETE' }); + } +} + +// Export singleton instance +export const apiClient = new ApiClient(BACKEND_URL); diff --git a/src/store/authSlice.ts b/src/store/authSlice.ts index 2ebbb5497..b7a39ca3f 100644 --- a/src/store/authSlice.ts +++ b/src/store/authSlice.ts @@ -1,4 +1,5 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; +import { clearUser } from './userSlice'; interface AuthState { token: string | null; @@ -17,7 +18,7 @@ const authSlice = createSlice({ setToken: (state, action: PayloadAction) => { state.token = action.payload; }, - clearToken: (state) => { + _clearToken: (state) => { state.token = null; state.isOnboarded = false; }, @@ -27,5 +28,11 @@ const authSlice = createSlice({ }, }); -export const { setToken, clearToken, setOnboarded } = authSlice.actions; +// Thunk that clears both token and user data +export const clearToken = createAsyncThunk('auth/clearToken', async (_, { dispatch }) => { + dispatch(authSlice.actions._clearToken()); + dispatch(clearUser()); +}); + +export const { setToken, setOnboarded } = authSlice.actions; export default authSlice.reducer; diff --git a/src/store/index.ts b/src/store/index.ts index c695e14bf..7aea6745c 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,8 +1,9 @@ -import { configureStore } from '@reduxjs/toolkit'; +import { configureStore, Middleware } from '@reduxjs/toolkit'; import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; import authReducer from './authSlice'; import socketReducer from './socketSlice'; +import userReducer from './userSlice'; // Persist config for auth only const authPersistConfig = { @@ -15,12 +16,12 @@ const authPersistConfig = { const persistedAuthReducer = persistReducer(authPersistConfig, authReducer); // Get logger only in dev mode -let loggerMiddleware: unknown = undefined; +let loggerMiddleware: Middleware | undefined = undefined; if (import.meta.env.DEV) { try { // eslint-disable-next-line @typescript-eslint/no-var-requires const createLogger = require('redux-logger'); - loggerMiddleware = createLogger.createLogger(); + loggerMiddleware = createLogger.createLogger() as Middleware; } catch { // Logger not available, continue without it } @@ -30,6 +31,7 @@ export const store = configureStore({ reducer: { auth: persistedAuthReducer, socket: socketReducer, + user: userReducer, }, middleware: (getDefaultMiddleware) => { const middleware = getDefaultMiddleware({ diff --git a/src/store/userSlice.ts b/src/store/userSlice.ts new file mode 100644 index 000000000..3c102848b --- /dev/null +++ b/src/store/userSlice.ts @@ -0,0 +1,69 @@ +import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; +import { userApi } from '../services/api/userApi'; +import type { User } from '../types/api'; + +interface UserState { + user: User | null; + isLoading: boolean; + error: string | null; +} + +const initialState: UserState = { + user: null, + isLoading: false, + error: null, +}; + +/** + * Async thunk to fetch current user data + */ +export const fetchCurrentUser = createAsyncThunk( + 'user/fetchCurrentUser', + async (_, { rejectWithValue }) => { + try { + const user = await userApi.getMe(); + return user; + } catch (error) { + const errorMessage = + error && typeof error === 'object' && 'error' in error + ? String(error.error) + : 'Failed to fetch user data'; + return rejectWithValue(errorMessage); + } + } +); + +const userSlice = createSlice({ + name: 'user', + initialState, + reducers: { + setUser: (state, action: PayloadAction) => { + state.user = action.payload; + state.error = null; + }, + clearUser: (state) => { + state.user = null; + state.error = null; + state.isLoading = false; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchCurrentUser.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase(fetchCurrentUser.fulfilled, (state, action) => { + state.isLoading = false; + state.user = action.payload; + state.error = null; + }) + .addCase(fetchCurrentUser.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + }, +}); + +export const { setUser, clearUser } = userSlice.actions; +export default userSlice.reducer; diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 000000000..e060d5785 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,70 @@ +// API Response wrapper +export interface ApiResponse { + success: boolean; + data: T; + error?: string; + message?: string; +} + +// API Error response +export interface ApiError { + success: false; + error: string; + message?: string; +} + +// User types based on backend ITgUser model +export interface UserSubscription { + hasActiveSubscription: boolean; + plan: 'FREE' | 'BASIC' | 'PRO'; + planExpiry?: string; + stripeCustomerId?: string; +} + +export interface UserUsage { + dailyTokenLimit: number; + remainingTokens: number; + activeSessionCount: number; + lastTokenResetAt?: string; +} + +export interface UserReferral { + inviteCode?: string | null; + inviteCodeUsages: number; + maxInviteCodeUsages?: number | null; + inviteCodeUsedAt?: string; + invitedBy?: string | null; + pendingInviteCode?: string | null; +} + +export interface UserSettings { + dailySummariesEnabled: boolean; + dailySummaryUtcTriggerHour?: number; + dailySummaryChatIds: number[]; + autoCompleteEnabled: boolean; + autoCompleteVisibility: 'always' | 'groups_only' | 'private_chats_only'; + autoCompleteWhitelistChatIds: number[]; + autoCompleteBlacklistChatIds: number[]; +} + +export interface User { + id: string; + telegramId: number; + hasAccess: boolean; + magicWord: string; + referral: UserReferral; + subscription: UserSubscription; + usage: UserUsage; + role: 'admin' | 'team' | 'user'; + settings: UserSettings; + autoDeleteTelegramMessagesAfterDays: number; + autoDeleteThreadsAfterDays: number; + firstName?: string; + lastName?: string; + username?: string; + languageCode?: string; + waitlist?: string; +} + +// API Endpoints +export type GetMeResponse = ApiResponse;