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.firstname !== undefined) formData.append('firstname', payload.firstname);
|
||||||
if (payload.lastname !== undefined) formData.append('lastname', payload.lastname);
|
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.email_signature !== undefined) formData.append('email_signature', payload.email_signature);
|
||||||
if (payload.password) formData.append('password', payload.password);
|
if (payload.password) formData.append('password', payload.password);
|
||||||
if (payload.passwordr) formData.append('passwordr', payload.passwordr);
|
if (payload.passwordr) formData.append('passwordr', payload.passwordr);
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import Icon from 'react-native-vector-icons/Ionicons';
|
import Icon from 'react-native-vector-icons/Ionicons';
|
||||||
@ -143,11 +142,7 @@ export const ProjectActivityFeed = ({
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ScrollView
|
<View style={styles.listContent}>
|
||||||
style={styles.listContent}
|
|
||||||
scrollEnabled={false}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{displayData.map((item, index) => (
|
{displayData.map((item, index) => (
|
||||||
<ActivityRow
|
<ActivityRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@ -158,7 +153,7 @@ export const ProjectActivityFeed = ({
|
|||||||
textMutedColor={colors.textMuted}
|
textMutedColor={colors.textMuted}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</View>
|
||||||
|
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import Icon from 'react-native-vector-icons/Ionicons';
|
|||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { useTheme } from '@theme';
|
import { useTheme } from '@theme';
|
||||||
import { FormInput, Loader } from '@components';
|
import { FormInput, Loader } from '@components';
|
||||||
import { useAppDispatch, useAppSelector, RootState, updateUserData } from '@store';
|
import { useAppDispatch, useAppSelector, RootState } from '@store';
|
||||||
import { getStyles } from './editProfile.styles';
|
import { getStyles } from './editProfile.styles';
|
||||||
import { updateProfile, getStaffDetails, resetEditProfileState } from './thunk';
|
import { updateProfile, getStaffDetails, resetEditProfileState } from './thunk';
|
||||||
import ImagePicker from 'react-native-image-crop-picker';
|
import ImagePicker from 'react-native-image-crop-picker';
|
||||||
@ -43,6 +43,9 @@ export const EditProfileScreen = () => {
|
|||||||
const [profileImageMime, setProfileImageMime] = useState('image/jpeg');
|
const [profileImageMime, setProfileImageMime] = useState('image/jpeg');
|
||||||
const [profileImageName, setProfileImageName] = useState('profile_image.jpg');
|
const [profileImageName, setProfileImageName] = useState('profile_image.jpg');
|
||||||
const [isImageChanged, setIsImageChanged] = useState(false);
|
const [isImageChanged, setIsImageChanged] = useState(false);
|
||||||
|
// UI toggle state
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
const resolveServerUrl = async (path?: string | null) => {
|
const resolveServerUrl = async (path?: string | null) => {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
@ -62,36 +65,26 @@ export const EditProfileScreen = () => {
|
|||||||
return `${cleanBase}/${cleanRelPath}`;
|
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
|
// Fetch staff details on mount
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// if (userData?.staffid) {
|
if (userData?.staffid) {
|
||||||
// dispatch(getStaffDetails(userData.staffid));
|
dispatch(getStaffDetails(userData.staffid));
|
||||||
// }
|
}
|
||||||
// }, [dispatch, userData?.staffid]);
|
}, [dispatch, userData?.staffid]);
|
||||||
|
|
||||||
// Update local form state when details are loaded from the server
|
// Update local form state when details are loaded from the server
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// if (staffDetails) {
|
if (staffDetails) {
|
||||||
// setFirstname(staffDetails.firstname || '');
|
setFirstname(staffDetails.firstname || '');
|
||||||
// setLastname(staffDetails.lastname || '');
|
setLastname(staffDetails.lastname || '');
|
||||||
// setEmail(staffDetails.email || '');
|
setEmail(staffDetails.email || '');
|
||||||
// setPhone(staffDetails.phonenumber || '');
|
setPhone(staffDetails.phonenumber || '');
|
||||||
// setEmailSignature(staffDetails.email_signature || '');
|
setEmailSignature(staffDetails.email_signature || '');
|
||||||
// resolveServerUrl(userData?.profile_image).then((url) => {
|
resolveServerUrl(userData?.profile_image).then((url) => {
|
||||||
// setProfileImageUri(url);
|
setProfileImageUri(url);
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
// }, [staffDetails, userData?.profile_image]);
|
}, [staffDetails, userData?.profile_image]);
|
||||||
|
|
||||||
// Reset edit profile state on unmount
|
// Reset edit profile state on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -100,6 +93,34 @@ export const EditProfileScreen = () => {
|
|||||||
};
|
};
|
||||||
}, [dispatch]);
|
}, [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 = () => {
|
const handleCameraLaunch = () => {
|
||||||
ImagePicker.openCamera({
|
ImagePicker.openCamera({
|
||||||
width: 400,
|
width: 400,
|
||||||
@ -203,40 +224,13 @@ export const EditProfileScreen = () => {
|
|||||||
email,
|
email,
|
||||||
firstname: firstname || undefined,
|
firstname: firstname || undefined,
|
||||||
lastname: lastname || undefined,
|
lastname: lastname || undefined,
|
||||||
phone: phone || undefined,
|
phonenumber: phone || undefined,
|
||||||
email_signature: emailSignature || undefined,
|
email_signature: emailSignature || undefined,
|
||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
passwordr: passwordr || undefined,
|
passwordr: passwordr || undefined,
|
||||||
profile_image: profileImagePayload,
|
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 || '';
|
const staffId = userData?.staffid || '';
|
||||||
|
|||||||
@ -114,9 +114,9 @@ export const LeadsScreen = () => {
|
|||||||
placeholder="Search leads, companies, emails..."
|
placeholder="Search leads, companies, emails..."
|
||||||
style={styles.searchInput}
|
style={styles.searchInput}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity style={styles.filterButton}>
|
{/* <TouchableOpacity style={styles.filterButton}>
|
||||||
<Icon name="filter-outline" size={20} color={colors.text} />
|
<Icon name="filter-outline" size={20} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity> */}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Status count cards */}
|
{/* Status count cards */}
|
||||||
|
|||||||
@ -17,8 +17,8 @@ import { useTheme } from '@theme';
|
|||||||
import { useAppDispatch, useAppSelector, RootState, login } from '@store';
|
import { useAppDispatch, useAppSelector, RootState, login } from '@store';
|
||||||
import { LoginRequest } from '@interfaces';
|
import { LoginRequest } from '@interfaces';
|
||||||
import { NotificationService } from '@services';
|
import { NotificationService } from '@services';
|
||||||
import { backGroundImage } from '@utils';
|
import { backGroundImage, TENANCY_OPTIONS } from '@utils';
|
||||||
import { FormInput } from '@components';
|
import { FormInput, FormPicker } from '@components';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import DeviceInfo from 'react-native-device-info';
|
import DeviceInfo from 'react-native-device-info';
|
||||||
|
|
||||||
@ -97,8 +97,14 @@ export const LoginScreen = () => {
|
|||||||
const savedPassword = await AsyncStorage.getItem('password');
|
const savedPassword = await AsyncStorage.getItem('password');
|
||||||
|
|
||||||
if (savedBaseUrl) {
|
if (savedBaseUrl) {
|
||||||
const cleanBaseUrl = savedBaseUrl.replace(/^(https?:\/\/)?/, '');
|
const matched = TENANCY_OPTIONS.find(
|
||||||
setTenancy(cleanBaseUrl);
|
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 (savedEmail) setEmail(savedEmail);
|
||||||
if (savedPassword) setPassword(savedPassword);
|
if (savedPassword) setPassword(savedPassword);
|
||||||
@ -180,39 +186,13 @@ export const LoginScreen = () => {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Tenancy Field */}
|
{/* Tenancy Environment Field */}
|
||||||
<FormInput
|
<FormPicker
|
||||||
label="Tenancy Name"
|
label="Tenancy Name"
|
||||||
value={tenancy}
|
value={tenancy}
|
||||||
onChangeText={(text) => {
|
onValueChange={(val) => setTenancy(val)}
|
||||||
const cleaned = text.replace(/^(https?:\/\/)?/, '');
|
options={TENANCY_OPTIONS}
|
||||||
setTenancy(cleaned);
|
placeholder="Select Tenancy Environment"
|
||||||
}}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Email Field */}
|
{/* Email Field */}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ export interface UpdateProfilePayload {
|
|||||||
email: string;
|
email: string;
|
||||||
firstname?: string;
|
firstname?: string;
|
||||||
lastname?: string;
|
lastname?: string;
|
||||||
phone?: string;
|
phonenumber?: string;
|
||||||
email_signature?: string;
|
email_signature?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
passwordr?: string;
|
passwordr?: string;
|
||||||
|
|||||||
@ -156,4 +156,10 @@ export const getStyles = (colors: ThemeColors) =>
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#EF4444',
|
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>
|
<Text style={styles.logoutText}>Sign Out</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.versionText}>App Version: 1.0.0</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { createAction, createReducer } from '@reduxjs/toolkit';
|
import { createReducer } from '@reduxjs/toolkit';
|
||||||
import { login } from './thunk';
|
import { login } from './thunk';
|
||||||
import { UserData } from '@interfaces';
|
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 {
|
export interface LoginState {
|
||||||
loginSuccess: boolean;
|
loginSuccess: boolean;
|
||||||
@ -50,9 +49,10 @@ export const authReducer = createReducer(initialState, builder => {
|
|||||||
acc.loginMessage = (payload as string) ?? error.message ?? 'Login failed';
|
acc.loginMessage = (payload as string) ?? error.message ?? 'Login failed';
|
||||||
acc.loginSuccess = false;
|
acc.loginSuccess = false;
|
||||||
})
|
})
|
||||||
.addCase(updateUserData, (acc, action) => {
|
// Sync auth user_data after a successful profile update
|
||||||
if (acc.user_data) {
|
.addCase(updateProfile.fulfilled, (acc, action) => {
|
||||||
acc.user_data = { ...acc.user_data, ...action.payload };
|
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 axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
|
||||||
import config from 'react-native-config';
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { getAuthTokenForBaseUrl } from '@utils';
|
||||||
import {Store} from '@reduxjs/toolkit';
|
import {Store} from '@reduxjs/toolkit';
|
||||||
|
|
||||||
const axiosInstance: AxiosInstance = axios.create({
|
const axiosInstance: AxiosInstance = axios.create({
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
authtoken: config.AUTH_TOKEN,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
axiosInstance.interceptors.request.use(
|
axiosInstance.interceptors.request.use(
|
||||||
async request => {
|
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');
|
const storedBaseUrl = await AsyncStorage.getItem('base_url');
|
||||||
if (storedBaseUrl) {
|
if (storedBaseUrl) {
|
||||||
request.baseURL = storedBaseUrl;
|
request.baseURL = storedBaseUrl;
|
||||||
|
|
||||||
|
const authToken = getAuthTokenForBaseUrl(storedBaseUrl);
|
||||||
|
if (authToken) {
|
||||||
|
request.headers.authtoken = authToken;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.data instanceof FormData) {
|
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 './api';
|
||||||
export * from './helper';
|
export * from './helper';
|
||||||
export * from './navigationRef';
|
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