diff --git a/app/containers/Passcode/PasscodeEnter.tsx b/app/containers/Passcode/PasscodeEnter.tsx index 05e4ad46d1a..934bf5ac467 100644 --- a/app/containers/Passcode/PasscodeEnter.tsx +++ b/app/containers/Passcode/PasscodeEnter.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from 'react'; -import { useAsyncStorage } from '@react-native-async-storage/async-storage'; import { gestureHandlerRootHOC } from 'react-native-gesture-handler'; import * as Haptics from 'expo-haptics'; import { sha256 } from 'js-sha256'; @@ -10,7 +9,7 @@ import { TYPE } from './constants'; import { ATTEMPTS_KEY, LOCKED_OUT_TIMER_KEY, MAX_ATTEMPTS, PASSCODE_KEY } from '../../lib/constants/localAuthentication'; import { biometryAuth, resetAttempts } from '../../lib/methods/helpers/localAuthentication'; import { getDiff, getLockedUntil } from './utils'; -import { useUserPreferences } from '../../lib/methods/userPreferences'; +import UserPreferences, { useUserPreferences } from '../../lib/methods/userPreferences'; import I18n from '../../i18n'; interface IPasscodePasscodeEnter { @@ -20,12 +19,9 @@ interface IPasscodePasscodeEnter { const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) => { const ref = useRef(null); - let attempts = 0; - let lockedUntil: any = false; + const attempts = parseInt(UserPreferences.getString(ATTEMPTS_KEY) || '0', 10); const [passcode] = useUserPreferences(PASSCODE_KEY); const [status, setStatus] = useState(null); - const { setItem: setAttempts } = useAsyncStorage(ATTEMPTS_KEY); - const { setItem: setLockedUntil } = useAsyncStorage(LOCKED_OUT_TIMER_KEY); const biometry = async () => { if (hasBiometry && status === TYPE.ENTER) { @@ -37,7 +33,7 @@ const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) = }; const readStorage = async () => { - lockedUntil = await getLockedUntil(); + const lockedUntil = await getLockedUntil(); if (lockedUntil) { const diff = getDiff(lockedUntil); if (diff <= 1) { @@ -61,14 +57,14 @@ const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) = if (sha256(p) === passcode) { finishProcess(); } else { - attempts += 1; - if (attempts >= MAX_ATTEMPTS) { + const nextAttempts = attempts + 1; + if (nextAttempts >= MAX_ATTEMPTS) { setStatus(TYPE.LOCKED); - setLockedUntil(new Date().toISOString()); + UserPreferences.setString(LOCKED_OUT_TIMER_KEY, new Date().toISOString()); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); } else { ref?.current?.wrongPasscode(); - setAttempts(attempts?.toString()); + UserPreferences.setString(ATTEMPTS_KEY, nextAttempts.toString()); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); } } diff --git a/app/containers/Passcode/utils.ts b/app/containers/Passcode/utils.ts index 8a07b6dbbb4..1e3b99c3a24 100644 --- a/app/containers/Passcode/utils.ts +++ b/app/containers/Passcode/utils.ts @@ -1,14 +1,15 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +import UserPreferences from '../../lib/methods/userPreferences'; import dayjs from '../../lib/dayjs'; import { LOCKED_OUT_TIMER_KEY, TIME_TO_LOCK } from '../../lib/constants/localAuthentication'; export const getLockedUntil = async () => { - const t = await AsyncStorage.getItem(LOCKED_OUT_TIMER_KEY); + const t = UserPreferences.getString(LOCKED_OUT_TIMER_KEY); if (t) { return dayjs(t).add(TIME_TO_LOCK, 'millisecond').toDate(); } return null; }; + export const getDiff = (t: string | number | Date) => new Date(t).getTime() - new Date().getTime(); diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 8f9606fe55d..37de79125da 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -1002,6 +1002,7 @@ "User_not_found_or": "User not found or incorrect password", "User_sent_an_attachment": "{{user}} sent an attachment", "Username": "Username", + "Username_invalid": "Use only letters, numbers, dots, hyphens and underscores", "Username_is_already_in_use": "Username is already in use.", "Username_not_available": "Username not available", "Username_or_email": "Username or email", diff --git a/app/lib/methods/helpers/localAuthentication.ts b/app/lib/methods/helpers/localAuthentication.ts index 8dd1b005f55..3753b7563a4 100644 --- a/app/lib/methods/helpers/localAuthentication.ts +++ b/app/lib/methods/helpers/localAuthentication.ts @@ -1,6 +1,5 @@ import * as LocalAuthentication from 'expo-local-authentication'; import RNBootSplash from 'react-native-bootsplash'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { sha256 } from 'js-sha256'; import dayjs from '../../dayjs'; @@ -48,7 +47,11 @@ export const saveLastLocalAuthenticationSession = async ( }); }; -export const resetAttempts = (): Promise => AsyncStorage.multiRemove([LOCKED_OUT_TIMER_KEY, ATTEMPTS_KEY]); +export const resetAttempts = (): Promise => { + UserPreferences.removeItem(LOCKED_OUT_TIMER_KEY); + UserPreferences.removeItem(ATTEMPTS_KEY); + return Promise.resolve(); +}; const openModal = (hasBiometry: boolean, force?: boolean) => new Promise((resolve, reject) => { diff --git a/app/views/ProfileView/index.test.tsx b/app/views/ProfileView/index.test.tsx index 0fe9bc0cb4b..03a99c22511 100644 --- a/app/views/ProfileView/index.test.tsx +++ b/app/views/ProfileView/index.test.tsx @@ -9,6 +9,7 @@ import { twoFactor } from '../../lib/services/twoFactor'; import handleSaveUserProfileError from '../../lib/methods/helpers/handleSaveUserProfileError'; import EventEmitter from '../../lib/methods/helpers/events'; import { setUser } from '../../actions/login'; +import I18n from '../../i18n'; jest.mock('react-redux', () => ({ useDispatch: jest.fn() @@ -69,7 +70,8 @@ const buildState = () => ({ Accounts_AllowUserAvatarChange: true, Accounts_AllowUsernameChange: true, Accounts_CustomFields: '', - Accounts_AllowDeleteOwnAccount: true + Accounts_AllowDeleteOwnAccount: true, + UTF8_User_Names_Validation: '[0-9a-zA-Z-_.]+' } }); @@ -110,6 +112,27 @@ describe('ProfileView submit', () => { expect(handleSaveUserProfileError).not.toHaveBeenCalled(); }); + it('does not save when the username contains invalid characters', async () => { + const { getByTestId, findByText } = renderProfile(); + + fireEvent.changeText(getByTestId('profile-view-username'), 'cygnus b'); + fireEvent.press(getByTestId('profile-view-submit')); + + await findByText(I18n.t('Username_invalid')); + expect(saveUserProfile).not.toHaveBeenCalled(); + }); + + it('saves when the username is corrected to a valid value', async () => { + (saveUserProfile as jest.Mock).mockResolvedValue(true); + + const { getByTestId } = renderProfile(); + + fireEvent.changeText(getByTestId('profile-view-username'), 'cygnus.b'); + fireEvent.press(getByTestId('profile-view-submit')); + + await waitFor(() => expect(saveUserProfile).toHaveBeenCalledWith({ username: 'cygnus.b' }, {})); + }); + it('asks for the current password before changing the email', async () => { const { getByTestId } = renderProfile(); diff --git a/app/views/ProfileView/index.tsx b/app/views/ProfileView/index.tsx index 6b4366d58da..34496096703 100644 --- a/app/views/ProfileView/index.tsx +++ b/app/views/ProfileView/index.tsx @@ -46,16 +46,25 @@ import ConfirmEmailChangeActionSheetContent from './components/ConfirmEmailChang const MAX_BIO_LENGTH = 260; const MAX_NICKNAME_LENGTH = 120; +// Mirror the server's username validation, which matches the value against the +// UTF8_User_Names_Validation setting anchored as a full match (`^pattern$`), +// falling back to the default pattern when the setting is empty or invalid. +// https://github.com/RocketChat/Rocket.Chat/blob/develop/apps/meteor/app/lib/server/functions/validateUsername.ts +const DEFAULT_USERNAME_VALIDATION = '[0-9a-zA-Z-_.]+'; + +const isValidUsername = (username: string, pattern: string): boolean => { + try { + return new RegExp(`^(${pattern || DEFAULT_USERNAME_VALIDATION})$`).test(username); + } catch { + // Fall back to the default pattern if the server provides an invalid regex. + return new RegExp(`^(${DEFAULT_USERNAME_VALIDATION})$`).test(username); + } +}; + interface IProfileViewProps { navigation: NativeStackNavigationProp; } const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { - const validationSchema = yup.object().shape({ - name: yup.string().required(I18n.t('Name_required')), - email: yup.string().email(I18n.t('Email_must_be_a_valid_email')).required(I18n.t('Email_required')), - username: yup.string().required(I18n.t('Username_required')) - }); - const { showActionSheet, hideActionSheet } = useActionSheet(); const { colors } = useTheme(); const dispatch = useDispatch(); @@ -67,6 +76,7 @@ const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { Accounts_AllowUserAvatarChange, Accounts_AllowUsernameChange, Accounts_CustomFields, + UTF8_User_Names_Validation, serverVersion, user } = useAppSelector(state => ({ @@ -77,9 +87,19 @@ const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { Accounts_AllowUserAvatarChange: state.settings.Accounts_AllowUserAvatarChange as boolean, Accounts_AllowUsernameChange: state.settings.Accounts_AllowUsernameChange as boolean, Accounts_CustomFields: state.settings.Accounts_CustomFields as string, + UTF8_User_Names_Validation: state.settings.UTF8_User_Names_Validation as string, serverVersion: state.server.version, Accounts_AllowDeleteOwnAccount: state.settings.Accounts_AllowDeleteOwnAccount as boolean })); + + const validationSchema = yup.object().shape({ + name: yup.string().required(I18n.t('Name_required')), + email: yup.string().email(I18n.t('Email_must_be_a_valid_email')).required(I18n.t('Email_required')), + username: yup + .string() + .required(I18n.t('Username_required')) + .test('valid-username', I18n.t('Username_invalid'), value => isValidUsername(value ?? '', UTF8_User_Names_Validation)) + }); const isMasterDetail = useMasterDetail(); const { control,