login flow integrated

This commit is contained in:
yashwin-foxy 2025-09-10 18:23:49 +05:30
parent 70b9198aa4
commit 0df78919f2
8 changed files with 359 additions and 108 deletions

View File

@ -20,7 +20,7 @@ import IntegrationsNavigator from '@/modules/integrations/navigation/Integration
import { StatusBar } from 'react-native'; import { StatusBar } from 'react-native';
function AppContent(): React.JSX.Element { function AppContent(): React.JSX.Element {
const isAuthenticated = useSelector((s: RootState) => Boolean(s.auth.token)); const isAuthenticated = useSelector((s: RootState) => Boolean(s.auth.isAuthenticated));
const selectedService = useSelector((s: RootState) => s.integrations.selectedService); const selectedService = useSelector((s: RootState) => s.integrations.selectedService);
const linking = { const linking = {
prefixes: ['centralizedreportingsystem://', 'https://centralizedreportingsystem.com'], prefixes: ['centralizedreportingsystem://', 'https://centralizedreportingsystem.com'],

View File

@ -6,33 +6,99 @@ import {
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
Pressable, Pressable,
Alert,
} from 'react-native'; } from 'react-native';
import GradientBackground from '@/shared/components/layout/GradientBackground'; import GradientBackground from '@/shared/components/layout/GradientBackground';
import LinearGradient from 'react-native-linear-gradient'; import LinearGradient from 'react-native-linear-gradient';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { login } from '@/modules/auth/store/authSlice'; import { login, clearError } from '@/modules/auth/store/authSlice';
import type { RootState } from '@/store/store'; import type { RootState, AppDispatch } from '@/store/store';
import { useTheme } from '@/shared/styles/useTheme'; import { useTheme } from '@/shared/styles/useTheme';
import { validateLoginForm } from '@/shared/utils/validation';
const LoginScreen: React.FC = () => { const LoginScreen: React.FC = () => {
const dispatch = useDispatch(); const dispatch = useDispatch<AppDispatch>();
const { colors, fonts } = useTheme(); const { colors, fonts } = useTheme();
const { loading, error } = useSelector((s: RootState) => s.auth); const { loading, error, isAuthenticated } = useSelector((s: RootState) => s.auth);
const [email, setEmail] = React.useState(''); const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState(''); const [password, setPassword] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false); const [showPassword, setShowPassword] = React.useState(false);
const [rememberMe, setRememberMe] = React.useState(false); const [rememberMe, setRememberMe] = React.useState(false);
const [focused, setFocused] = React.useState<null | 'email' | 'password'>(null); const [focused, setFocused] = React.useState<null | 'email' | 'password'>(null);
const [validationErrors, setValidationErrors] = React.useState<{ email?: string; password?: string }>({});
const emailRef = React.useRef<TextInput>(null); const emailRef = React.useRef<TextInput>(null);
const passwordRef = React.useRef<TextInput>(null); const passwordRef = React.useRef<TextInput>(null);
const handleLogin = () => { // Clear validation errors when user starts typing
// @ts-ignore const handleEmailChange = (text: string) => {
dispatch(login({ email, password })); setEmail(text);
if (validationErrors.email) {
setValidationErrors(prev => ({ ...prev, email: undefined }));
}
// Clear API error when user starts typing
if (error) {
dispatch(clearError());
}
}; };
const handlePasswordChange = (text: string) => {
setPassword(text);
if (validationErrors.password) {
setValidationErrors(prev => ({ ...prev, password: undefined }));
}
// Clear API error when user starts typing
if (error) {
dispatch(clearError());
}
};
const handleLogin = async () => {
// Clear previous validation errors
setValidationErrors({});
// Validate form inputs
const validation = validateLoginForm(email, password);
if (!validation.isValid) {
setValidationErrors(validation.errors);
return;
}
try {
// Dispatch login action
const result = await dispatch(login({ email, password }));
// Check if login was successful
if (login.fulfilled.match(result)) {
// Login successful - navigation will be handled by the app navigator
// based on isAuthenticated state
Alert.alert('Success', 'Login successful!', [
{ text: 'OK', style: 'default' }
]);
}
} catch (err) {
// Error handling is done in the slice
console.error('Login error:', err);
}
};
// Clear error when component mounts or when user starts typing
React.useEffect(() => {
if (error) {
dispatch(clearError());
}
}, [dispatch]);
// Auto-focus email input when component mounts
React.useEffect(() => {
const timer = setTimeout(() => {
emailRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}, []);
return ( return (
<GradientBackground preset="warm" style={styles.gradient}> <GradientBackground preset="warm" style={styles.gradient}>
<View style={styles.container}> <View style={styles.container}>
@ -47,72 +113,112 @@ const LoginScreen: React.FC = () => {
<Text style={[styles.subtitle, { color: colors.textLight, fontFamily: fonts.regular }]}>Enter your email and password to log in</Text> <Text style={[styles.subtitle, { color: colors.textLight, fontFamily: fonts.regular }]}>Enter your email and password to log in</Text>
{/* Email input */} {/* Email input */}
<Pressable <View>
style={[ <Pressable
styles.inputWrapper, style={[
{ styles.inputWrapper,
borderColor: focused === 'email' ? colors.primary : colors.border, {
backgroundColor: colors.surface, borderColor: validationErrors.email
}, ? colors.error
]} : focused === 'email'
onPress={() => emailRef.current?.focus()} ? colors.primary
> : colors.border,
<Icon name="email-outline" size={20} color={focused === 'email' ? colors.primary : colors.textLight} /> backgroundColor: colors.surface,
<TextInput },
ref={emailRef} ]}
placeholder="Email" onPress={() => emailRef.current?.focus()}
placeholderTextColor={colors.textLight} >
autoCapitalize="none" <Icon
autoCorrect={false} name="email-outline"
autoComplete="email" size={20}
keyboardType="email-address" color={validationErrors.email
returnKeyType="next" ? colors.error
value={email} : focused === 'email'
onFocus={() => setFocused('email')} ? colors.primary
onBlur={() => setFocused(null)} : colors.textLight
onChangeText={setEmail} }
onSubmitEditing={() => passwordRef.current?.focus()} />
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]} <TextInput
/> ref={emailRef}
</Pressable> placeholder="Email"
placeholderTextColor={colors.textLight}
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
keyboardType="email-address"
returnKeyType="next"
value={email}
onFocus={() => setFocused('email')}
onBlur={() => setFocused(null)}
onChangeText={handleEmailChange}
onSubmitEditing={() => passwordRef.current?.focus()}
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
/>
</Pressable>
{validationErrors.email && (
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
{validationErrors.email}
</Text>
)}
</View>
{/* Password input */} {/* Password input */}
<Pressable <View>
style={[ <Pressable
styles.inputWrapper, style={[
{ styles.inputWrapper,
borderColor: focused === 'password' ? colors.primary : colors.border, {
backgroundColor: colors.surface, borderColor: validationErrors.password
}, ? colors.error
]} : focused === 'password'
onPress={() => passwordRef.current?.focus()} ? colors.primary
> : colors.border,
<Icon name="lock-outline" size={20} color={focused === 'password' ? colors.primary : colors.textLight} /> backgroundColor: colors.surface,
<TextInput },
ref={passwordRef} ]}
placeholder="Password" onPress={() => passwordRef.current?.focus()}
placeholderTextColor={colors.textLight}
secureTextEntry={!showPassword}
textContentType="password"
autoComplete="off"
autoCorrect={false}
autoCapitalize="none"
value={password}
onFocus={() => setFocused('password')}
onBlur={() => setFocused(null)}
onChangeText={setPassword}
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
/>
<TouchableOpacity
style={styles.iconButton}
onPress={() => setShowPassword(v => !v)}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
accessibilityRole="button"
accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
> >
<Icon name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={22} color={colors.textLight} /> <Icon
</TouchableOpacity> name="lock-outline"
</Pressable> size={20}
color={validationErrors.password
? colors.error
: focused === 'password'
? colors.primary
: colors.textLight
}
/>
<TextInput
ref={passwordRef}
placeholder="Password"
placeholderTextColor={colors.textLight}
secureTextEntry={!showPassword}
textContentType="password"
autoComplete="off"
autoCorrect={false}
autoCapitalize="none"
value={password}
onFocus={() => setFocused('password')}
onBlur={() => setFocused(null)}
onChangeText={handlePasswordChange}
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
/>
<TouchableOpacity
style={styles.iconButton}
onPress={() => setShowPassword(v => !v)}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
accessibilityRole="button"
accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
>
<Icon name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={22} color={colors.textLight} />
</TouchableOpacity>
</Pressable>
{validationErrors.password && (
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
{validationErrors.password}
</Text>
)}
</View>
{/* Row: Remember me + Forgot password */} {/* Row: Remember me + Forgot password */}
<View style={styles.rowBetween}> <View style={styles.rowBetween}>
@ -127,9 +233,24 @@ const LoginScreen: React.FC = () => {
</View> </View>
{/* Login button */} {/* Login button */}
<TouchableOpacity activeOpacity={0.9} onPress={handleLogin} disabled={loading} style={{ marginTop: 12 }}> <TouchableOpacity
<LinearGradient colors={["#3AA0FF", "#2D6BFF"]} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.loginButton}> activeOpacity={0.9}
<Text style={[styles.loginButtonText, { fontFamily: fonts.bold }]}>{loading ? 'Logging in...' : 'Log In'}</Text> onPress={handleLogin}
disabled={loading || !email.trim() || !password.trim()}
style={{ marginTop: 12 }}
>
<LinearGradient
colors={loading || !email.trim() || !password.trim()
? [colors.border, colors.border]
: ["#3AA0FF", "#2D6BFF"]
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.loginButton}
>
<Text style={[styles.loginButtonText, { fontFamily: fonts.bold }]}>
{loading ? 'Logging in...' : 'Log In'}
</Text>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
@ -161,8 +282,15 @@ const LoginScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Error */} {/* API Error */}
{!!error && <Text style={[styles.errorText, { fontFamily: fonts.regular }]}>{error}</Text>} {!!error && (
<View style={styles.errorContainer}>
<Icon name="alert-circle-outline" size={16} color={colors.error} />
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
{error}
</Text>
</View>
)}
</View> </View>
</View> </View>
</GradientBackground> </GradientBackground>
@ -287,10 +415,17 @@ const styles = StyleSheet.create({
signupText: { signupText: {
fontSize: 12, fontSize: 12,
}, },
errorText: { errorContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12, marginTop: 12,
color: '#EF4444', paddingHorizontal: 8,
textAlign: 'center', },
errorText: {
marginLeft: 6,
fontSize: 14,
textAlign: 'left',
flex: 1,
}, },
}); });

View File

@ -1,31 +1,78 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { authAPI } from '../services/authAPI';
// API Response Types
interface LoginSuccessResponse {
status: 'success';
message: string;
data: {
accessToken: string;
refreshToken: string;
user: AuthUser;
};
timestamp: string;
}
interface LoginErrorResponse {
status: 'error';
message: string;
errorCode: string;
timestamp: string;
}
type LoginResponse = LoginSuccessResponse | LoginErrorResponse;
export interface AuthUser { export interface AuthUser {
id: string; id: number;
name: string; uuid: string;
email: string; email: string;
displayName: string;
role: string;
} }
export interface AuthState { export interface AuthState {
user: AuthUser | null; user: AuthUser | null;
token: string | null; accessToken: string | null;
refreshToken: string | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
isAuthenticated: boolean;
} }
const initialState: AuthState = { const initialState: AuthState = {
user: null, user: null,
token: null, accessToken: null,
refreshToken: null,
loading: false, loading: false,
error: null, error: null,
isAuthenticated: false,
}; };
export const login = createAsyncThunk( export const login = createAsyncThunk(
'auth/login', 'auth/login',
async (payload: { email: string; password: string }) => { async (payload: { email: string; password: string }, { rejectWithValue }) => {
// TODO: integrate real API try {
await new Promise(r => setTimeout(r, 300)); const response = await authAPI.login(payload.email, payload.password);
return { token: 'mock-token', user: { id: '1', name: 'User', email: payload.email } as AuthUser }; console.log('login response igot',response)
const data = response.data as LoginResponse;
if (data.status === 'success') {
return {
accessToken: data.data.accessToken,
refreshToken: data.data.refreshToken,
user: data.data.user,
};
} else {
return rejectWithValue(data.message || 'Login failed');
}
} catch (error: any) {
// Handle network errors or API errors
if (error.response?.data?.status === 'error') {
const errorData = error.response.data as LoginErrorResponse;
return rejectWithValue(errorData.message || 'Login failed');
}
return rejectWithValue(error.message || 'Network error occurred');
}
}, },
); );
@ -35,7 +82,12 @@ const authSlice = createSlice({
reducers: { reducers: {
logout: state => { logout: state => {
state.user = null; state.user = null;
state.token = null; state.accessToken = null;
state.refreshToken = null;
state.error = null;
state.isAuthenticated = false;
},
clearError: state => {
state.error = null; state.error = null;
}, },
}, },
@ -45,19 +97,23 @@ const authSlice = createSlice({
state.loading = true; state.loading = true;
state.error = null; state.error = null;
}) })
.addCase(login.fulfilled, (state, action: PayloadAction<{ token: string; user: AuthUser }>) => { .addCase(login.fulfilled, (state, action) => {
state.loading = false; state.loading = false;
state.token = action.payload.token; state.accessToken = action.payload.accessToken;
state.refreshToken = action.payload.refreshToken;
state.user = action.payload.user; state.user = action.payload.user;
state.isAuthenticated = true;
state.error = null;
}) })
.addCase(login.rejected, (state, action) => { .addCase(login.rejected, (state, action) => {
state.loading = false; state.loading = false;
state.error = action.error.message || 'Login failed'; state.error = action.payload as string || 'Login failed';
state.isAuthenticated = false;
}); });
}, },
}); });
export const { logout } = authSlice.actions; export const { logout, clearError } = authSlice.actions;
export default authSlice; export default authSlice;

View File

@ -12,6 +12,9 @@ import { WebView } from 'react-native-webview';
import Icon from 'react-native-vector-icons/MaterialIcons'; import Icon from 'react-native-vector-icons/MaterialIcons';
import { useTheme } from '@/shared/styles/useTheme'; import { useTheme } from '@/shared/styles/useTheme';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { manageToken } from '../services/integrationAPI';
import { useSelector } from 'react-redux';
import { RootState } from '@/store/store';
// Types // Types
type ServiceKey = 'zohoProjects' | 'zohoCRM' | 'zohoBooks' | 'zohoPeople'; type ServiceKey = 'zohoProjects' | 'zohoCRM' | 'zohoBooks' | 'zohoPeople';
@ -63,6 +66,8 @@ const getScopeForService = (_serviceKey?: ServiceKey): string => {
'ZohoBooks.FullAccess.READ', 'ZohoBooks.FullAccess.READ',
// Zoho People // Zoho People
'ZohoPeople.employee.READ', 'ZohoPeople.employee.READ',
//can read the user info
'aaaserver.profile.READ'
].join(','); ].join(',');
}; };
@ -143,7 +148,7 @@ const ZohoAuth: React.FC<ZohoAuthProps> = ({
const { colors, spacing, fonts, shadows } = useTheme(); const { colors, spacing, fonts, shadows } = useTheme();
const webViewRef = useRef<WebView>(null); const webViewRef = useRef<WebView>(null);
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
const { user,accessToken } = useSelector((state: RootState) => state.auth);
const currentScope = useMemo(() => getScopeForService(serviceKey), [serviceKey]); const currentScope = useMemo(() => getScopeForService(serviceKey), [serviceKey]);
const [state, setState] = useState<ZohoAuthState>({ const [state, setState] = useState<ZohoAuthState>({
@ -153,9 +158,11 @@ const ZohoAuth: React.FC<ZohoAuthProps> = ({
}); });
// Backend exchange mode: only log and return the authorization code to the caller // Backend exchange mode: only log and return the authorization code to the caller
const handleAuthorizationCode = useCallback((authCode: string) => { const handleAuthorizationCode = useCallback(async (authCode: string) => {
console.log('[ZohoAuth] Authorization code received:', authCode); console.log('[ZohoAuth] Authorization code received:', authCode);
console.log('[ZohoAuth] Send this code to your backend to exchange for tokens.'); console.log('[ZohoAuth] Send this code to your backend to exchange for tokens.');
const response = await manageToken.manageToken({ authorization_code: authCode, id: user?.id, service_name: 'zoho', access_token: accessToken });
console.log('[ZohoAuth] Response from manageToken:', response);
// Return the code via onAuthSuccess using the existing shape // Return the code via onAuthSuccess using the existing shape
onAuthSuccess?.({ onAuthSuccess?.({
accessToken: authCode, // This is the AUTHORIZATION CODE, not an access token accessToken: authCode, // This is the AUTHORIZATION CODE, not an access token
@ -194,18 +201,18 @@ const ZohoAuth: React.FC<ZohoAuthProps> = ({
navigation.navigate('Dashboard' as never, params as never); navigation.navigate('Dashboard' as never, params as never);
return; return;
} }
if (lowerUrl.includes('profile')) { // if (lowerUrl.includes('profile')) {
// Close auth view if provided and navigate to Profile tab // // Close auth view if provided and navigate to Profile tab
const params = getAllQueryParamsFromUrl(url || ''); // const params = getAllQueryParamsFromUrl(url || '');
// Prefer `userId` param when present and numeric // // Prefer `userId` param when present and numeric
if (typeof params.userId === 'string' && /^-?\d+$/.test(params.userId)) { // if (typeof params.userId === 'string' && /^-?\d+$/.test(params.userId)) {
params.userId = Number(params.userId); // params.userId = Number(params.userId);
} // }
onClose?.(); // onClose?.();
// Route into Profile stack's default screen with params // // Route into Profile stack's default screen with params
navigation.navigate('Profile' as never, { screen: 'Profile', params } as never); // navigation.navigate('Profile' as never, { screen: 'Profile', params } as never);
return; // return;
} // }
// Fallback: if redirect URI without code or with error // Fallback: if redirect URI without code or with error
if (isRedirectUri(url) && !loading) { if (isRedirectUri(url) && !loading) {

View File

@ -0,0 +1,7 @@
import http from '@/services/http';
import { API_ENDPOINTS } from '@/shared/constants/API_ENDPOINTS';
export const manageToken = {
manageToken: (data: { authorization_code: string, id: string|undefined, service_name: string|undefined ,access_token:string}) =>
http.post(API_ENDPOINTS.MANAGE_TOKEN, { authorization_code:data.authorization_code,id:data.id,service_name:data.service_name },{headers:{'Authorization':`Bearer ${data.access_token}`}}),
};

View File

@ -1,7 +1,7 @@
import { create } from 'apisauce'; import { create } from 'apisauce';
const http = create({ const http = create({
baseURL: 'https://api.example.com', baseURL: 'http://192.168.1.16:4000',
timeout: 10000, timeout: 10000,
}); });

View File

@ -1,5 +1,7 @@
export const API_ENDPOINTS = { export const API_ENDPOINTS = {
AUTH_LOGIN: '/auth/login', AUTH_LOGIN: '/api/v1/auth/login',
USERSIGNUP:'/api/v1/users/register',
MANAGE_TOKEN:'/api/v1/users/zoho/token ',
HR_METRICS: '/hr/metrics', HR_METRICS: '/hr/metrics',
ZOHO_PROJECTS: '/zoho/projects', ZOHO_PROJECTS: '/zoho/projects',
PROFILE: '/profile', PROFILE: '/profile',

View File

@ -0,0 +1,44 @@
// Validation utility functions for form inputs
export interface ValidationResult {
isValid: boolean;
error?: string;
}
export const validateEmail = (email: string): ValidationResult => {
if (!email.trim()) {
return { isValid: false, error: 'Email is required' };
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return { isValid: false, error: 'Please enter a valid email address' };
}
return { isValid: true };
};
export const validatePassword = (password: string): ValidationResult => {
if (!password) {
return { isValid: false, error: 'Password is required' };
}
if (password.length < 6) {
return { isValid: false, error: 'Password must be at least 6 characters long' };
}
return { isValid: true };
};
export const validateLoginForm = (email: string, password: string): { isValid: boolean; errors: { email?: string; password?: string } } => {
const emailValidation = validateEmail(email);
const passwordValidation = validatePassword(password);
return {
isValid: emailValidation.isValid && passwordValidation.isValid,
errors: {
email: emailValidation.error,
password: passwordValidation.error,
},
};
};