mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Add UserProvider and user data management with Redux
- Introduced UserProvider to automatically fetch user data when a JWT token is available, enhancing user experience by ensuring data is readily accessible. - Created useUser hook for accessing user data and managing loading states, streamlining the process of fetching and utilizing user information. - Implemented userSlice for managing user state in Redux, including actions for fetching and clearing user data, improving state management consistency. - Added apiClient for handling API requests, including error handling and authentication, to facilitate communication with the backend. These changes enhance the application's user data management and improve the overall architecture by integrating user state with Redux.
This commit is contained in:
+6
-3
@@ -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() {
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<SocketProvider>
|
||||
<Router>
|
||||
<AppRoutes />
|
||||
</Router>
|
||||
<UserProvider>
|
||||
<Router>
|
||||
<AppRoutes />
|
||||
</Router>
|
||||
</UserProvider>
|
||||
</SocketProvider>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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<User> => {
|
||||
const response = await apiClient.get<GetMeResponse>('/telegram/me');
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to fetch user data');
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -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<string, string>;
|
||||
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<string, string> = {
|
||||
'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<T>(
|
||||
endpoint: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
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<T>(endpoint: string, options?: Omit<RequestOptions, 'method' | 'body'>): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
async post<T>(
|
||||
endpoint: string,
|
||||
body?: unknown,
|
||||
options?: Omit<RequestOptions, 'method' | 'body'>
|
||||
): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...options, method: 'POST', body });
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
async put<T>(
|
||||
endpoint: string,
|
||||
body?: unknown,
|
||||
options?: Omit<RequestOptions, 'method' | 'body'>
|
||||
): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...options, method: 'PUT', body });
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH request
|
||||
*/
|
||||
async patch<T>(
|
||||
endpoint: string,
|
||||
body?: unknown,
|
||||
options?: Omit<RequestOptions, 'method' | 'body'>
|
||||
): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...options, method: 'PATCH', body });
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
async delete<T>(endpoint: string, options?: Omit<RequestOptions, 'method' | 'body'>): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const apiClient = new ApiClient(BACKEND_URL);
|
||||
+10
-3
@@ -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<string>) => {
|
||||
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;
|
||||
|
||||
+5
-3
@@ -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({
|
||||
|
||||
@@ -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<User | null>) => {
|
||||
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;
|
||||
@@ -0,0 +1,70 @@
|
||||
// API Response wrapper
|
||||
export interface ApiResponse<T> {
|
||||
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<User>;
|
||||
Reference in New Issue
Block a user