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;