feat(app): support multi-tenancy

This commit is contained in:
uttam05111990 2026-08-12 18:21:02 +05:30
parent 18077e260c
commit b967baa235
13 changed files with 144 additions and 112 deletions

View File

@ -11,7 +11,7 @@ export const updateProfileApi = async (
if (payload.firstname !== undefined) formData.append('firstname', payload.firstname);
if (payload.lastname !== undefined) formData.append('lastname', payload.lastname);
if (payload.phone !== undefined) formData.append('phone', payload.phone);
if (payload.phonenumber !== undefined) formData.append('phonenumber', payload.phonenumber);
if (payload.email_signature !== undefined) formData.append('email_signature', payload.email_signature);
if (payload.password) formData.append('password', payload.password);
if (payload.passwordr) formData.append('passwordr', payload.passwordr);

View File

@ -3,7 +3,6 @@ import {
View,
Text,
ActivityIndicator,
ScrollView,
TouchableOpacity,
} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
@ -143,11 +142,7 @@ export const ProjectActivityFeed = ({
</View>
) : (
<>
<ScrollView
style={styles.listContent}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
>
<View style={styles.listContent}>
{displayData.map((item, index) => (
<ActivityRow
key={item.id}
@ -158,7 +153,7 @@ export const ProjectActivityFeed = ({
textMutedColor={colors.textMuted}
/>
))}
</ScrollView>
</View>
{hasMore && (
<View style={styles.footer}>

View File

@ -15,7 +15,7 @@ import Icon from 'react-native-vector-icons/Ionicons';
import { useNavigation } from '@react-navigation/native';
import { useTheme } from '@theme';
import { FormInput, Loader } from '@components';
import { useAppDispatch, useAppSelector, RootState, updateUserData } from '@store';
import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getStyles } from './editProfile.styles';
import { updateProfile, getStaffDetails, resetEditProfileState } from './thunk';
import ImagePicker from 'react-native-image-crop-picker';
@ -43,6 +43,9 @@ export const EditProfileScreen = () => {
const [profileImageMime, setProfileImageMime] = useState('image/jpeg');
const [profileImageName, setProfileImageName] = useState('profile_image.jpg');
const [isImageChanged, setIsImageChanged] = useState(false);
// UI toggle state
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const resolveServerUrl = async (path?: string | null) => {
if (!path) return '';
@ -62,36 +65,26 @@ export const EditProfileScreen = () => {
return `${cleanBase}/${cleanRelPath}`;
};
// Fetch staff details on mount
useEffect(() => {
if (userData?.staffid) {
dispatch(getStaffDetails(userData.staffid));
}
}, [dispatch, userData?.staffid]);
// Update local form state when details are loaded from the server
useEffect(() => {
if (staffDetails) {
setFirstname(staffDetails.firstname || '');
setLastname(staffDetails.lastname || '');
setEmail(staffDetails.email || '');
setPhone(staffDetails.phonenumber || '');
setEmailSignature(staffDetails.email_signature || '');
resolveServerUrl(userData?.profile_image).then((url) => {
setProfileImageUri(url);
});
}, [userData?.profile_image]);
// UI toggle state
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Fetch staff details on mount
// useEffect(() => {
// if (userData?.staffid) {
// dispatch(getStaffDetails(userData.staffid));
// }
// }, [dispatch, userData?.staffid]);
// Update local form state when details are loaded from the server
// useEffect(() => {
// if (staffDetails) {
// setFirstname(staffDetails.firstname || '');
// setLastname(staffDetails.lastname || '');
// setEmail(staffDetails.email || '');
// setPhone(staffDetails.phonenumber || '');
// setEmailSignature(staffDetails.email_signature || '');
// resolveServerUrl(userData?.profile_image).then((url) => {
// setProfileImageUri(url);
// });
// }
// }, [staffDetails, userData?.profile_image]);
}
}, [staffDetails, userData?.profile_image]);
// Reset edit profile state on unmount
useEffect(() => {
@ -100,6 +93,34 @@ export const EditProfileScreen = () => {
};
}, [dispatch]);
// Navigate back on success
useEffect(() => {
if (successMessage) {
Alert.alert('Success', successMessage, [
{
text: 'OK',
onPress: () => {
dispatch(resetEditProfileState());
navigation.goBack();
},
},
]);
}
}, [successMessage]);
// Show error message from state
useEffect(() => {
if (error) {
Alert.alert('Error', error, [
{
text: 'OK',
onPress: () => dispatch(resetEditProfileState()),
},
]);
}
}, [error]);
const handleCameraLaunch = () => {
ImagePicker.openCamera({
width: 400,
@ -203,40 +224,13 @@ export const EditProfileScreen = () => {
email,
firstname: firstname || undefined,
lastname: lastname || undefined,
phone: phone || undefined,
phonenumber: phone || undefined,
email_signature: emailSignature || undefined,
password: password || undefined,
passwordr: passwordr || undefined,
profile_image: profileImagePayload,
}),
)
.unwrap()
.then((response) => {
// Success
const updatedImage = response.user_data?.profile_image || userData?.profile_image;
dispatch(
updateUserData({
firstname,
lastname,
email,
phonenumber: phone,
email_signature: emailSignature,
profile_image: updatedImage,
}),
);
Alert.alert('Success', 'Profile updated successfully.', [
{
text: 'OK',
onPress: () => {
dispatch(resetEditProfileState());
navigation.goBack();
},
},
]);
})
.catch((err) => {
Alert.alert('Error', err || 'Failed to update profile');
});
};
const staffId = userData?.staffid || '';

View File

@ -114,9 +114,9 @@ export const LeadsScreen = () => {
placeholder="Search leads, companies, emails..."
style={styles.searchInput}
/>
<TouchableOpacity style={styles.filterButton}>
{/* <TouchableOpacity style={styles.filterButton}>
<Icon name="filter-outline" size={20} color={colors.text} />
</TouchableOpacity>
</TouchableOpacity> */}
</View>
{/* Status count cards */}

View File

@ -17,8 +17,8 @@ import { useTheme } from '@theme';
import { useAppDispatch, useAppSelector, RootState, login } from '@store';
import { LoginRequest } from '@interfaces';
import { NotificationService } from '@services';
import { backGroundImage } from '@utils';
import { FormInput } from '@components';
import { backGroundImage, TENANCY_OPTIONS } from '@utils';
import { FormInput, FormPicker } from '@components';
import AsyncStorage from '@react-native-async-storage/async-storage';
import DeviceInfo from 'react-native-device-info';
@ -97,8 +97,14 @@ export const LoginScreen = () => {
const savedPassword = await AsyncStorage.getItem('password');
if (savedBaseUrl) {
const cleanBaseUrl = savedBaseUrl.replace(/^(https?:\/\/)?/, '');
setTenancy(cleanBaseUrl);
const matched = TENANCY_OPTIONS.find(
opt =>
opt.value.trim().replace(/\/+$/, '').toLowerCase() ===
savedBaseUrl.trim().replace(/\/+$/, '').toLowerCase(),
);
setTenancy(matched ? matched.value : savedBaseUrl);
} else if (TENANCY_OPTIONS.length > 0) {
setTenancy(TENANCY_OPTIONS[0].value);
}
if (savedEmail) setEmail(savedEmail);
if (savedPassword) setPassword(savedPassword);
@ -180,39 +186,13 @@ export const LoginScreen = () => {
</View>
) : null}
{/* Tenancy Field */}
<FormInput
{/* Tenancy Environment Field */}
<FormPicker
label="Tenancy Name"
value={tenancy}
onChangeText={(text) => {
const cleaned = text.replace(/^(https?:\/\/)?/, '');
setTenancy(cleaned);
}}
prefix="https://"
placeholder="company-name"
autoCapitalize="none"
autoCorrect={false}
containerStyle={styles.fieldContainer}
inputContainerStyle={[
styles.inputWrapper,
focusedField === 'tenancy' && styles.inputWrapperFocused,
]}
onFocus={() => {
setFocusedField('tenancy');
setTimeout(() => scrollViewRef.current?.scrollToEnd({ animated: true }), 100);
}}
onBlur={() => setFocusedField(null)}
leftIcon={
<Icon
name="business-outline"
size={20}
color={
focusedField === 'tenancy'
? colors.icon
: colors.textSecondary
}
/>
}
onValueChange={(val) => setTenancy(val)}
options={TENANCY_OPTIONS}
placeholder="Select Tenancy Environment"
/>
{/* Email Field */}

View File

@ -3,7 +3,7 @@ export interface UpdateProfilePayload {
email: string;
firstname?: string;
lastname?: string;
phone?: string;
phonenumber?: string;
email_signature?: string;
password?: string;
passwordr?: string;

View File

@ -156,4 +156,10 @@ export const getStyles = (colors: ThemeColors) =>
fontWeight: '700',
color: '#EF4444',
},
versionText: {
fontSize: 11,
color: colors.textMuted,
textAlign: 'center',
marginTop: 10,
},
});

View File

@ -222,6 +222,7 @@ export const CustomDrawerContent = (props: DrawerContentComponentProps) => {
/>
<Text style={styles.logoutText}>Sign Out</Text>
</TouchableOpacity>
<Text style={styles.versionText}>App Version: 1.0.0</Text>
</View>
</View>
);

View File

@ -1,9 +1,8 @@
import { createAction, createReducer } from '@reduxjs/toolkit';
import { createReducer } from '@reduxjs/toolkit';
import { login } from './thunk';
import { UserData } from '@interfaces';
import { updateProfile } from '../../../features/editProfile/thunk';
// Patch user_data in auth state after a successful profile update
export const updateUserData = createAction<Partial<UserData>>('auth/updateUserData');
export interface LoginState {
loginSuccess: boolean;
@ -50,9 +49,10 @@ export const authReducer = createReducer(initialState, builder => {
acc.loginMessage = (payload as string) ?? error.message ?? 'Login failed';
acc.loginSuccess = false;
})
.addCase(updateUserData, (acc, action) => {
if (acc.user_data) {
acc.user_data = { ...acc.user_data, ...action.payload };
// Sync auth user_data after a successful profile update
.addCase(updateProfile.fulfilled, (acc, action) => {
if (acc.user_data && action.payload.user_data) {
acc.user_data = { ...acc.user_data, ...action.payload.user_data };
}
});
});

View File

@ -1,22 +1,26 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
import config from 'react-native-config';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getAuthTokenForBaseUrl } from '@utils';
import {Store} from '@reduxjs/toolkit';
const axiosInstance: AxiosInstance = axios.create({
timeout: 15000,
headers: {
'Content-Type': 'application/json',
authtoken: config.AUTH_TOKEN,
},
});
axiosInstance.interceptors.request.use(
async request => {
// Dynamically set baseURL from AsyncStorage on every request
// Dynamically set baseURL and authtoken from AsyncStorage on every request
const storedBaseUrl = await AsyncStorage.getItem('base_url');
if (storedBaseUrl) {
request.baseURL = storedBaseUrl;
const authToken = getAuthTokenForBaseUrl(storedBaseUrl);
if (authToken) {
request.headers.authtoken = authToken;
}
}
if (request.data instanceof FormData) {

33
app/utils/enviroment.ts Normal file
View File

@ -0,0 +1,33 @@
import config from 'react-native-config';
export const normalizeUrl = (url?: string): string => {
if (!url) return '';
return url.trim().replace(/\/+$/, '').toLowerCase();
};
export const getAuthTokenForBaseUrl = (baseUrl: string | null): string => {
if (!baseUrl) return '';
const currentUrl = normalizeUrl(baseUrl);
const envConfigs = [
{
baseUrl: config.DEMO_CONVEXCRM_BASE_URL,
authToken: config.DEMO_CONVEXCRM_AUTH_TOKEN,
},
{
baseUrl: config.CONVEXSOL_CONVEXCRM_BASE_URL,
authToken: config.CONVEXSOL_CONVEXCRM_AUTH_TOKEN,
},
{
baseUrl: config.INTERNAL_CONVEXCRM_BASE_URL,
authToken: config.INTERNAL_CONVEXCRM_AUTH_TOKEN,
},
];
const matched = envConfigs.find(
item => item.baseUrl && normalizeUrl(item.baseUrl) === currentUrl,
);
return matched?.authToken || '';
};

View File

@ -3,3 +3,5 @@ export * from './assets';
export * from './api';
export * from './helper';
export * from './navigationRef';
export * from './tenencyOptions';
export * from './enviroment';

View File

@ -0,0 +1,17 @@
import config from 'react-native-config';
import { FormPickerOption } from '@components';
export const TENANCY_OPTIONS: FormPickerOption[] = [
{
label: 'Demo Convex CRM (demo-convexcrm.convexsol.co)',
value: config.DEMO_CONVEXCRM_BASE_URL || '',
},
{
label: 'ConvexSol CRM (convexsol.convexcrm.com)',
value: config.CONVEXSOL_CONVEXCRM_BASE_URL || '',
},
{
label: 'Internal CRM (internal.convexcrm.com)',
value: config.INTERNAL_CONVEXCRM_BASE_URL || '',
},
];