diff --git a/.env.example b/.env.example index 92375ec1e..36a0e4ab5 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ EXPO_PUBLIC_CHATWOOT_WEBSITE_TOKEN= EXPO_PUBLIC_CHATWOOT_BASE_URL=https://app.chatwoot.com EXPO_PUBLIC_JUNE_SDK_KEY= -EXPO_PUBLIC_MINIMUM_CHATWOOT_VERSION=3.13.0 +EXPO_PUBLIC_MINIMUM_CHATWOOT_VERSION=4.1.0 EXPO_PUBLIC_SENTRY_DSN= EXPO_PUBLIC_PROJECT_ID= EXPO_PUBLIC_APP_SLUG= diff --git a/app.config.ts b/app.config.ts index 53c76f16c..60d027509 100644 --- a/app.config.ts +++ b/app.config.ts @@ -4,7 +4,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { return { name: 'Chatwoot', slug: process.env.EXPO_PUBLIC_APP_SLUG || 'chatwoot-mobile', - version: '4.2.0', + version: '4.2.3', orientation: 'portrait', icon: './assets/icon.png', userInterfaceStyle: 'light', diff --git a/jest.config.js b/jest.config.js index cbb7b65d8..7b698d81a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,4 +4,7 @@ module.exports = { moduleNameMapper: { '^@/(.*)$': '/src/$1', }, + transformIgnorePatterns: [ + 'node_modules/(?!(jest-)?@?react-native|@react-native-community|@react-navigation|@reduxjs|immer)', + ], }; diff --git a/package.json b/package.json index 0853519d5..1cb0d3212 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/mobile-app", - "version": "4.2.0", + "version": "4.2.3", "scripts": { "start": "expo start --dev-client", "start:production": "expo start --no-dev --minify", diff --git a/src/components-next/index.ts b/src/components-next/index.ts index 6231a27c4..a8912d790 100644 --- a/src/components-next/index.ts +++ b/src/components-next/index.ts @@ -6,3 +6,4 @@ export * from './sheet-components'; export * from './spinner'; export * from './action-tabs'; export * from './no-network'; +export * from './verification-code'; diff --git a/src/components-next/verification-code/animated-code-number.tsx b/src/components-next/verification-code/animated-code-number.tsx new file mode 100644 index 000000000..6c9cc0ed2 --- /dev/null +++ b/src/components-next/verification-code/animated-code-number.tsx @@ -0,0 +1,76 @@ +import { StyleSheet } from 'react-native'; +import type { SharedValue } from 'react-native-reanimated'; +import Animated, { + Easing, + FadeIn, + FadeOut, + FlipInXDown, + FlipOutXDown, + useAnimatedStyle, + withTiming, +} from 'react-native-reanimated'; + +export type StatusType = 'inProgress' | 'correct' | 'wrong'; + +export type AnimatedCodeNumberProps = { + code?: string; + highlighted: boolean; + status: SharedValue; +}; + +export const AnimatedCodeNumber: React.FC = ({ + code, + highlighted, + status, +}) => { + const correctColor = 'hsl(151, 40.2%, 54.1%)'; // green-600 + const defaultColor = 'hsl(0, 0%, 89.5%)'; // gray-300 + + const rBoxStyle = useAnimatedStyle(() => { + return { + // Only show green border for correct status, default border for all other states + borderColor: withTiming(status.value === 'correct' ? correctColor : defaultColor), + }; + }, [correctColor, defaultColor]); + + return ( + + {code != null && ( + + + {code} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: '90%', + width: '80%', + borderWidth: 2, + borderRadius: 12, + borderCurve: 'continuous', + justifyContent: 'center', + alignItems: 'center', + }, + text: { + fontSize: 40, + fontFamily: 'Inter-500-24', + }, +}); diff --git a/src/components-next/verification-code/hooks/use-animated-shake.ts b/src/components-next/verification-code/hooks/use-animated-shake.ts new file mode 100644 index 000000000..d4dfdac35 --- /dev/null +++ b/src/components-next/verification-code/hooks/use-animated-shake.ts @@ -0,0 +1,47 @@ +import { useCallback } from 'react'; +import { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +// Custom hook for creating an animated shaking effect +const useAnimatedShake = () => { + // Shared value to track the horizontal translation for shaking + const shakeTranslateX = useSharedValue(0); + + // Callback function to trigger the shake animation + const shake = useCallback(() => { + // Cancel any ongoing animations on shakeTranslateX + cancelAnimation(shakeTranslateX); + + // Reset the translation value to 0 before starting the new animation + shakeTranslateX.value = 0; + + // Apply a repeating animation to create a shaking effect + shakeTranslateX.value = withRepeat( + withTiming(10, { + duration: 120, + easing: Easing.bezier(0.35, 0.7, 0.5, 0.7), + }), + 6, // Repeat the animation 6 times + true, // Infinite loop (true indicates indefinite repetition) + ); + }, [shakeTranslateX]); // Dependency array to ensure proper re-rendering + + // Define the animated style based on the current translation value + const rShakeStyle = useAnimatedStyle(() => { + return { + transform: [{ translateX: shakeTranslateX.value }], + }; + }, []); + + // Return the shake callback function and the animated style for external use + return { shake, rShakeStyle }; +}; + +// Export the custom hook for use in other components +export { useAnimatedShake }; diff --git a/src/components-next/verification-code/index.tsx b/src/components-next/verification-code/index.tsx new file mode 100644 index 000000000..898acc260 --- /dev/null +++ b/src/components-next/verification-code/index.tsx @@ -0,0 +1,50 @@ +import { StyleSheet, View } from 'react-native'; +import { useSharedValue } from 'react-native-reanimated'; + +import type { AnimatedCodeNumberProps } from './animated-code-number'; +import { AnimatedCodeNumber } from './animated-code-number'; + +export type StatusType = 'inProgress' | 'correct' | 'wrong'; + +type VerificationCodeProps = { + code: string[]; + maxLength?: number; + isCodeWrong: boolean; +} & Pick; + +export const VerificationCode: React.FC = ({ + code, + maxLength = 5, + status, + isCodeWrong, +}) => { + const wrongStatus = useSharedValue('wrong'); + + return ( + + {new Array(maxLength).fill(0).map((_, index) => ( + + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + width: '100%', + flexDirection: 'row', + }, + codeContainer: { + aspectRatio: 0.95, + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/src/i18n/en.json b/src/i18n/en.json index f0b5e0a0e..0c10fe736 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -50,6 +50,24 @@ "SUB_TITLE": "Don't worry! We got your back", "API_SUCCESS": "Password reset link has been sent to your email address" }, + "MFA": { + "TITLE": "Two-Factor Authentication", + "TABS": { + "AUTHENTICATOR_APP": "Authenticator App", + "BACKUP_CODE": "Backup Code" + }, + "INSTRUCTIONS": { + "AUTHENTICATOR": "Enter 6-digit code from your authenticator app", + "BACKUP": "Enter your one of your backup code" + }, + "PLACEHOLDERS": { + "BACKUP_CODE": "Enter backup code" + }, + "BUTTONS": { + "VERIFY": "Verify", + "VERIFYING": "Verifying..." + } + }, "CONVERSATION": { "HEADER": { "TITLE": "Conversations", diff --git a/src/navigation/stack/AuthStack.tsx b/src/navigation/stack/AuthStack.tsx index a0e29153a..807006154 100644 --- a/src/navigation/stack/AuthStack.tsx +++ b/src/navigation/stack/AuthStack.tsx @@ -4,11 +4,13 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import ConfigInstallationURL from '@/screens/auth/ConfigURLScreen'; import Login from '@/screens/auth/LoginScreen'; import ForgotPassword from '@/screens/auth/ForgotPassword'; +import MFAScreen from '@/screens/auth/MFAScreen'; export type AuthStackParamList = { Login: undefined; ResetPassword: undefined; ConfigureURL: undefined; + MFAScreen: undefined; }; const Stack = createNativeStackNavigator(); @@ -45,6 +47,17 @@ export const AuthStack = () => { name="ConfigureURL" component={ConfigInstallationURL} /> + ); }; diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index 7abced6e2..e9c43cac4 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -83,7 +83,22 @@ const LoginScreen = () => { const onSubmit = async (data: FormData) => { const { email, password } = data; - dispatch(authActions.login({ email, password })); + // Clear any existing auth state before login + dispatch(resetAuth()); + + try { + const result = await dispatch(authActions.login({ email, password })).unwrap(); + + // Check if MFA is required in the response + if ('mfa_required' in result && result.mfa_required) { + // Navigate directly to MFA screen with the token + navigation.navigate('MFAScreen' as never); + } + // If MFA not required, the auth state will be updated and + // the app will automatically navigate to the dashboard + } catch { + // Login error is handled by Redux and displayed in the UI + } }; const openResetPassword = () => { diff --git a/src/screens/auth/MFAScreen.tsx b/src/screens/auth/MFAScreen.tsx new file mode 100644 index 000000000..b9b554ead --- /dev/null +++ b/src/screens/auth/MFAScreen.tsx @@ -0,0 +1,259 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Animated, StatusBar, View, TextInput, Text, Pressable, ScrollView } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useSharedValue } from 'react-native-reanimated'; +import { useNavigation } from '@react-navigation/native'; +import { Button, VerificationCode } from '@/components-next'; +import { useAnimatedShake } from '@/components-next/verification-code/hooks/use-animated-shake'; +import type { StatusType } from '@/components-next/verification-code'; +import { tailwind } from '@/theme'; +import { useAppDispatch, useAppSelector } from '@/hooks'; +import { resetSettings } from '@/store/settings/settingsSlice'; +import { authActions } from '@/store/auth/authActions'; +import { resetAuth, clearAuthError } from '@/store/auth/authSlice'; +import i18n from '@/i18n'; + +const MFAScreen = () => { + const navigation = useNavigation(); + const dispatch = useAppDispatch(); + const { mfaToken, uiFlags, error } = useAppSelector(state => state.auth); + + const [activeTab, setActiveTab] = useState<'authenticator' | 'backup'>('authenticator'); + const [code, setCode] = useState([]); + const [backupCode, setBackupCode] = useState(''); + const [isCodeWrong, setIsCodeWrong] = useState(false); + const hiddenInputRef = useRef(null); + const backupInputRef = useRef(null); + + const verificationStatus = useSharedValue('inProgress'); + const { shake, rShakeStyle } = useAnimatedShake(); + + useEffect(() => { + dispatch(resetSettings()); + }, [dispatch]); + + // Clear MFA token when navigating back to login + useEffect(() => { + const unsubscribe = navigation.addListener('beforeRemove', e => { + // Clear MFA token when leaving the screen + dispatch(resetAuth()); + }); + + return unsubscribe; + }, [navigation, dispatch]); + + const handleCodeChange = (text: string) => { + const newCode = text + .replace(/[^0-9]/g, '') + .split('') + .slice(0, 6); + setCode(newCode); + setIsCodeWrong(false); + if (error) dispatch(clearAuthError()); + verificationStatus.value = 'inProgress'; + + if (newCode.length === 6) { + handleVerify(newCode.join('')); + } + }; + + const handleVerify = async (enteredCode?: string) => { + if (!mfaToken) return; + + setIsCodeWrong(false); + dispatch(clearAuthError()); + + try { + const payload = { + mfa_token: mfaToken, + ...(activeTab === 'authenticator' + ? { otp_code: enteredCode || code.join('') } + : { backup_code: backupCode }), + }; + + await dispatch(authActions.verifyMfa(payload)).unwrap(); + + verificationStatus.value = 'correct'; + + // The app will automatically navigate to main app when user is set in auth state + // No manual navigation needed - the existing auth logic handles this + // eslint-disable-next-line + } catch (e) { + verificationStatus.value = 'wrong'; + setIsCodeWrong(true); + shake(); + + if (activeTab === 'authenticator') { + setCode([]); + if (hiddenInputRef.current) { + hiddenInputRef.current.clear(); + } + } else { + setBackupCode(''); + if (backupInputRef.current) { + backupInputRef.current.clear(); + } + } + } + }; + + return ( + + + + + + + {i18n.t('MFA.TITLE')} + + + + {/* Tab Selector */} + + { + setActiveTab('authenticator'); + setIsCodeWrong(false); + dispatch(clearAuthError()); + verificationStatus.value = 'inProgress'; + }}> + + {i18n.t('MFA.TABS.AUTHENTICATOR_APP')} + + + { + setActiveTab('backup'); + setIsCodeWrong(false); + dispatch(clearAuthError()); + verificationStatus.value = 'inProgress'; + }}> + + {i18n.t('MFA.TABS.BACKUP_CODE')} + + + + + {/* Code Input */} + + + {activeTab === 'authenticator' + ? i18n.t('MFA.INSTRUCTIONS.AUTHENTICATOR') + : i18n.t('MFA.INSTRUCTIONS.BACKUP')} + + + {activeTab === 'authenticator' ? ( + <> + hiddenInputRef.current?.focus()}> + + + + + + {/* Error message for authenticator */} + {error && ( + + {error} + + )} + + {!error && } + + {/* Hidden TextInput for OTP */} + + + ) : ( + <> + + { + setBackupCode(text); + setIsCodeWrong(false); + if (error) dispatch(clearAuthError()); + }} + placeholder={i18n.t('MFA.PLACEHOLDERS.BACKUP_CODE')} + keyboardType="default" + autoFocus + autoCorrect={false} + maxLength={8} + /> + + + {/* Error message for backup code */} + {error && ( + + {error} + + )} + + {!error && } + + )} + +