feat(app): support multi-tenancy
This commit is contained in:
parent
18077e260c
commit
b967baa235
@ -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);
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
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]);
|
||||
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]);
|
||||
|
||||
// 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 || '';
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -3,7 +3,7 @@ export interface UpdateProfilePayload {
|
||||
email: string;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
phone?: string;
|
||||
phonenumber?: string;
|
||||
email_signature?: string;
|
||||
password?: string;
|
||||
passwordr?: string;
|
||||
|
||||
@ -156,4 +156,10 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
fontWeight: '700',
|
||||
color: '#EF4444',
|
||||
},
|
||||
versionText: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
33
app/utils/enviroment.ts
Normal 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 || '';
|
||||
};
|
||||
@ -3,3 +3,5 @@ export * from './assets';
|
||||
export * from './api';
|
||||
export * from './helper';
|
||||
export * from './navigationRef';
|
||||
export * from './tenencyOptions';
|
||||
export * from './enviroment';
|
||||
|
||||
17
app/utils/tenencyOptions.ts
Normal file
17
app/utils/tenencyOptions.ts
Normal 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 || '',
|
||||
},
|
||||
];
|
||||
Loading…
x
Reference in New Issue
Block a user