Compare commits

...

21 Commits

Author SHA1 Message Date
Ammar Ahmed
9779685102 mobile: fix no success message on copying 2fa codes 2026-05-11 13:38:19 +05:00
Ammar Ahmed
f2925725b8 mobile: add input validation for 2fa method configuration 2026-05-11 13:38:17 +05:00
Ammar Ahmed
9ca9fe3f99 Merge pull request #9827 from streetwriters/fix/312
Fix copy button text is cut in 2fa settings page
2026-05-11 13:36:49 +05:00
Ammar Ahmed
8e5039fa7f Merge pull request #9831 from streetwriters/fix/289
Add form validation and error handling in change email on Mobile
2026-05-11 13:36:15 +05:00
Ammar Ahmed
2f47fe331d Merge pull request #9825 from streetwriters/fix/288
Add form validation in change password screen
2026-05-11 13:35:45 +05:00
Ammar Ahmed
238abee126 mobile: add form validation in change password screen 2026-05-11 13:35:28 +05:00
Ammar Ahmed
a8d0fa3b94 Merge pull request #9820 from streetwriters/fix/286
Fix rename attachment does not work on iOS
2026-05-11 13:33:00 +05:00
Ammar Ahmed
27b3cfcd23 Merge pull request #9819 from streetwriters/fix/285
Fix Reminder allows past time in edit mode without validation error
2026-05-11 13:32:39 +05:00
Ammar Ahmed
d4d7eac240 mobile: fix loading indicator causes cancel button to jump 2026-05-08 10:44:05 +05:00
Ammar Ahmed
e7fb246c25 mobile: show errors under input in rename dialog 2026-05-08 10:44:04 +05:00
Ammar Ahmed
03defb3a48 mobile: fix defaultValue is used if input is empty in dialog input 2026-05-08 10:42:25 +05:00
Ammar Ahmed
73c44d476b mobile: fix rename attachment does not work on iOS 2026-05-08 10:41:53 +05:00
Ammar Ahmed
61aaaba452 mobile: cleanup 2026-05-08 10:02:57 +05:00
Ammar Ahmed
9992bf8c32 mobile: add form validation in change email 2026-05-08 09:26:11 +05:00
Ammar Ahmed
b8b7d37799 core: remap Invalid token error to Invalid code in changeEmail 2026-05-08 09:24:39 +05:00
Abdullah Atta
0aaabfab62 web: exclude hidden settings from setting search (#9772)
* web: exclude hidden settings from search

* web: add support for searching setting section groups
2026-05-08 08:53:00 +05:00
Ammar Ahmed
14d680c092 mobile: show all errors inline with inputs/data-entry items 2026-05-07 18:42:47 +05:00
Ammar Ahmed
28f95b0061 mobile: fix plans keep loading on paywall screen 2026-05-07 13:01:38 +05:00
Ammar Ahmed
419dd555f4 mobile: fix reminder allows past time in edit mode without validation error 2026-05-07 13:01:37 +05:00
Ammar Ahmed
1312ce7d50 Merge pull request #9818 from streetwriters/fix/284
Trim title before checking if title is valid in add reminder screen
2026-05-07 12:58:46 +05:00
Ammar Ahmed
e023f11cd9 mobile: fix copy button text is cut in 2fa settings page 2026-05-07 09:22:59 +05:00
19 changed files with 610 additions and 249 deletions

View File

@@ -22,7 +22,7 @@ import { Attachment, Note, VirtualizedGrouping } from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import React, { RefObject, useEffect, useState } from "react";
import { View } from "react-native";
import { TextInput, View } from "react-native";
import { ActionSheetRef } from "react-native-actions-sheet";
import { ScrollView } from "react-native-gesture-handler";
import { db } from "../../common/database";
@@ -59,6 +59,7 @@ import Paragraph from "../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../utils/styles";
import Navigation from "../../services/navigation";
import { createFormRef, validators } from "../ui/input/form-input";
const Actions = ({
attachment,
@@ -153,24 +154,48 @@ const Actions = ({
{
name: strings.rename(),
onPress: () => {
presentDialog({
input: true,
title: strings.renameFile(),
defaultValue: attachment.filename,
positivePress: async (value) => {
if (value && value.length > 0) {
await db.attachments.add({
hash: attachment.hash,
filename: value
});
setFilename(value);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
}
return true;
},
positiveText: strings.rename()
});
close?.();
setTimeout(() => {
presentDialog({
title: strings.renameFile(),
form: {
formRef: createFormRef({
name: attachment.filename
}),
items: [
{
name: "name",
defaultValue: attachment.filename,
placeholder: strings.enterTitle(),
ref: React.createRef<TextInput | null>(),
validators: [validators.required(strings.nameIsRequired())]
}
],
onFormSubmit: async (form) => {
try {
const value = form.getValue("name");
await db.attachments.add({
hash: attachment.hash,
filename: value
});
setFilename(value);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
ToastManager.show({
message: `Attachment renamed to ${value}`,
type: "success"
});
return true;
} catch (e) {
form.setError("name", (e as Error).message);
return false;
}
}
},
positiveText: strings.rename()
});
}, 500);
},
icon: "form-textbox"
},

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useRef, useState } from "react";
import { View } from "react-native";
import { db } from "../../common/database";
@@ -26,42 +27,44 @@ import { eSendEvent, ToastManager } from "../../services/event-manager";
import Navigation from "../../services/navigation";
import { useUserStore } from "../../stores/use-user-store";
import { eOpenRecoveryKeyDialog } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Dialog } from "../dialog";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import Input from "../ui/input";
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import { Notice } from "../ui/notice";
import Paragraph from "../ui/typography/paragraph";
import { TextInput } from "react-native-gesture-handler";
export const ChangePassword = () => {
const passwordInputRef = useRef<TextInput>(null);
const password = useRef<string>(undefined);
const { colors } = useThemeColors();
const formRef = useRef(
createFormRef({
oldPassword: "",
password: ""
})
);
const oldPasswordInputRef = useRef<TextInput>(null);
const oldPassword = useRef<string>(undefined);
const [error, setError] = useState(false);
const passwordInputRef = useRef<TextInput>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const user = useUserStore((state) => state.user);
const changePassword = async () => {
setError(undefined);
formRef.current.clearErrors();
if (!user?.isEmailConfirmed) {
ToastManager.show({
heading: strings.emailNotConfirmed(),
message: strings.emailNotConfirmedDesc(),
type: "error",
context: "local"
});
setError(strings.emailNotConfirmedDesc());
return;
}
if (error || !oldPassword.current || !password.current) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
if (!formRef.current.validate()) {
return;
}
const values = formRef.current.getValues();
setLoading(true);
try {
const result = await BackupService.run(
@@ -74,8 +77,8 @@ export const ChangePassword = () => {
}
const passwordChanged = await db.user.changePassword(
oldPassword.current,
password.current
values.oldPassword,
values.password
);
if (!passwordChanged) {
@@ -91,15 +94,15 @@ export const ChangePassword = () => {
Navigation.goBack();
eSendEvent(eOpenRecoveryKeyDialog);
} catch (e) {
const message = (e as Error).message;
setLoading(false);
ToastManager.show({
heading: strings.passwordChangeFailed(),
message: (e as Error).message,
type: "error",
context: "local"
});
if (/old password/i.test(message)) {
formRef.current.setError("oldPassword", message);
} else {
setError(message);
}
}
setLoading(false);
};
return (
@@ -110,36 +113,61 @@ export const ChangePassword = () => {
}}
>
<Dialog context="change-password-dialog" />
<Input
<FormInput
name="oldPassword"
formRef={formRef}
fwdRef={oldPasswordInputRef}
onChangeText={(value) => {
oldPassword.current = value;
}}
loading={loading}
validators={[validators.required(strings.currentPasswordRequired())]}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.oldPassword()}
placeholder={strings.currentPassword()}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
onErrorCheck={(e) => setError(e)}
loading={loading}
validators={[validators.required(strings.passwordRequired())]}
returnKeyLabel={strings.next()}
returnKeyType="next"
secureTextEntry
validationType="password"
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.newPassword()}
onSubmitEditing={() => {
changePassword();
}}
/>
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error}
</Paragraph>
) : null}
<Notice text={strings.changePasswordNotice()} type="alert" />
<View style={{ height: 10 }} />

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { StyleSheet, View } from "react-native";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { notesnook } from "../../../e2e/test.ids";
import { getColorLinearShade } from "../../utils/colors";
@@ -79,6 +79,11 @@ const DialogButtons = ({
/>
<Paragraph color={colors.primary.accent}>{" " + doneText}</Paragraph>
</View>
) : loading ? (
<ActivityIndicator
size={AppFontSize.lg}
color={colors.primary.accent}
/>
) : (
<View />
)}
@@ -105,7 +110,6 @@ const DialogButtons = ({
style={{
marginLeft: 10
}}
loading={loading}
bold
type={positiveType || "transparent"}
title={positiveTitle}

View File

@@ -67,15 +67,13 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
// Handle form submission if form is available
if (dialogInfo?.form && formRef.current) {
inputRef.current?.blur();
setLoading(true);
try {
const isValid = await formRef.current.validate();
if (!isValid) {
setLoading(false);
return;
}
if (dialogInfo.form.onFormSubmit) {
setLoading(true);
const result = await dialogInfo.form.onFormSubmit(formRef.current);
if (result === false) {
setLoading(false);
@@ -93,7 +91,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
let result = false;
try {
result = await dialogInfo.positivePress(
values.current.inputValue || dialogInfo.defaultValue,
values.current.inputValue,
checked
);
} catch (e) {

View File

@@ -22,6 +22,7 @@ import {
ColorValue,
TextInput,
TextInputProps,
TextStyle,
TouchableOpacity,
View,
ViewStyle
@@ -220,6 +221,7 @@ interface FormInputProps extends TextInputProps {
inputStyle?: TextInputProps["style"];
containerStyle?: ViewStyle;
wrapperStyle?: ViewStyle;
errorStyle?: TextStyle;
}
export function FormInput({
@@ -245,6 +247,7 @@ export function FormInput({
onBlur,
onPress,
onChangeText,
errorStyle,
...restProps
}: FormInputProps) {
const { colors, isDark } = useThemeColors();
@@ -391,7 +394,7 @@ export function FormInput({
{fieldError ? (
<Paragraph
size={AppFontSize.xs}
style={{ marginTop: 5, color: colors.error.icon }}
style={[{ marginTop: 5, color: colors.error.icon }, errorStyle]}
>
<AppIcon
color={colors.error.accent}

View File

@@ -368,7 +368,14 @@ export const useActions = ({
inputPlaceholder: strings.name(),
defaultValue: item.title,
positivePress: async (value) => {
if (!value || value.trim().length === 0) return;
if (!value || value.trim().length === 0) {
ToastManager.error(
new Error(strings.nameIsRequired()),
undefined,
"local"
);
return;
}
await db.colors.add({
id: item.id,
title: value

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Plan, SubscriptionPlan } from "@notesnook/core";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useAsync } from "react-async-hook";
import { Platform } from "react-native";
import Config from "react-native-config";
@@ -181,6 +181,8 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
const [cancelPromo, setCancelPromo] = useState(false);
const [userCanRequestTrial, setUserCanRequestTrial] = useState(false);
const [webPricingPlans, setWebPricingPlans] = useState<Plan[]>([]);
const plansRef = useRef(plans);
plansRef.current = plans;
const getProduct = React.useCallback(
(planId: string, skuId: string) => {
@@ -190,11 +192,11 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
);
return (
plans.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
plans.find((p) => p.id === planId)?.products?.[skuId]
plansRef.current.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
plansRef.current.find((p) => p.id === planId)?.products?.[skuId]
);
},
[plans, webPricingPlans]
[webPricingPlans]
);
const getProductAndroid = (planId: string, skuId: string) => {
@@ -253,35 +255,41 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
useEffect(() => {
const loadPlans = async () => {
const items = await PremiumService.loadProductsAndSubs();
pricingPlans.forEach((plan) => {
plan.subscriptions = {};
plan.products = {};
plan.subscriptionSkuList.forEach((sku) => {
if (!plan.subscriptions) plan.subscriptions = {};
plan.subscriptions[sku] = items.subs.find((p) => p.productId === sku);
try {
const items = await PremiumService.loadProductsAndSubs();
pricingPlans.forEach((plan) => {
plan.subscriptions = {};
plan.products = {};
plan.subscriptionSkuList.forEach((sku) => {
if (!plan.subscriptions) plan.subscriptions = {};
plan.subscriptions[sku] = items.subs.find(
(p) => p.productId === sku
);
});
plan.productSkuList.forEach((sku) => {
if (!plan.products) plan.products = {};
plan.products[sku] = items.products.find(
(p) => p.productId === sku
);
});
});
plan.productSkuList.forEach((sku) => {
if (!plan.products) plan.products = {};
plan.products[sku] = items.products.find((p) => p.productId === sku);
});
});
setPlans([...pricingPlans]);
setUserCanRequestTrial(hasTrialOffer());
if (
Config.GITHUB_RELEASE === "true" &&
!SettingsService.getProperty("serverUrls")
) {
try {
const products = WebPlanCache || (await db.pricing.products());
WebPlanCache = products;
setWebPricingPlans(products);
} catch (e) {
/**
setPlans([...pricingPlans]);
setUserCanRequestTrial(hasTrialOffer());
if (
Config.GITHUB_RELEASE === "true" &&
!SettingsService.getProperty("serverUrls")
) {
try {
const products = WebPlanCache || (await db.pricing.products());
WebPlanCache = products;
setWebPricingPlans(products);
} catch (e) {
/**
empty */
}
}
}
setLoadingPlans(false);
setLoadingPlans(false);
} catch (e) {}
};
loadPlans();
}, [options?.promoOffer, cancelPromo, hasTrialOffer]);

View File

@@ -62,6 +62,7 @@ import FormInput, {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import AppIcon from "../../components/ui/AppIcon";
const ReminderModes =
Platform.OS === "ios"
@@ -140,6 +141,8 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
: null,
[reminder?.id]
);
const [dateError, setDateError] = useState<string>();
const [selectDayError, setSelectDayError] = useState<string>();
const showDatePicker = () => {
setDatePickerVisibility(true);
@@ -151,6 +154,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
const handleConfirm = (date: Date) => {
timer.current = setTimeout(() => {
setDateError(undefined);
hideDatePicker();
setDate(date);
}, 10);
@@ -181,12 +185,9 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
async function saveReminder() {
try {
if (!formRef.current.validate()) return;
if (
date.getTime() < Date.now() &&
reminderMode === "once" &&
!props.route.params.reminder
) {
throw new Error(strings.dateError());
if (date.getTime() < Date.now() && reminderMode === "once") {
setDateError(strings.dateError());
return;
}
if (
@@ -194,12 +195,15 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
recurringMode !== "day" &&
recurringMode !== "year" &&
selectedDays.length === 0
)
throw new Error(strings.selectDayError());
) {
setSelectDayError(strings.selectDayError());
return;
}
if (!date && reminderMode !== ReminderModes.Permanent) return;
if (!(await Notifications.checkAndRequestPermissions(true)))
throw new Error(strings.noNotificationPermission());
if (!date && reminderMode !== ReminderModes.Permanent) return;
date.setSeconds(0, 0);
@@ -476,6 +480,22 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
/>
))}
</ScrollView>
{selectDayError ? (
<Paragraph
size={AppFontSize.xs}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
color: colors.error.icon
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{selectDayError}
</Paragraph>
) : null}
</View>
) : null}
@@ -491,16 +511,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
<DateTimePickerModal
isVisible={isDatePickerVisible}
mode="datetime"
minimumDate={
reminderMode === "once" ? dayjs().toDate() : new Date(0)
}
onConfirm={handleConfirm}
onCancel={hideDatePicker}
isDarkModeEnabled={isDark}
firstDayOfWeek={weekFormat === "Mon" ? 1 : 0}
is24Hour={db.settings.getTimeFormat() === "24-hour"}
date={date || new Date(Date.now())}
themeVariant={isDark ? "dark" : "light"}
/>
<DatePicker
date={date}
minimumDate={
reminderMode === "once" ? dayjs().toDate() : new Date(0)
}
maximumDate={dayjs(date).add(3, "months").toDate()}
onDateChange={handleConfirm}
theme={isDark ? "dark" : "light"}
@@ -534,6 +561,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
}}
/>
)}
{dateError ? (
<Paragraph
size={AppFontSize.xs}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
color: colors.error.icon
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{dateError}
</Paragraph>
) : null}
</View>
)}

View File

@@ -21,6 +21,7 @@ import { sanitizeFilename, useIsFeatureAvailable } from "@notesnook/common";
import { strings } from "@notesnook/intl";
import { useThemeColors, VariantsWithStaticColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import isMobilePhone from "validator/lib/isMobilePhone";
import React, {
Dispatch,
SetStateAction,
@@ -28,7 +29,13 @@ import React, {
useRef,
useState
} from "react";
import { ActivityIndicator, Linking, Platform, View } from "react-native";
import {
ActivityIndicator,
Linking,
Platform,
TextInput,
View
} from "react-native";
import RNFetchBlob from "react-native-blob-util";
import { FlatList } from "react-native-gesture-handler";
import * as ScopedStorage from "react-native-scoped-storage";
@@ -37,7 +44,10 @@ import filesystem from "../../common/filesystem";
import DialogHeader from "../../components/dialog/dialog-header";
import { Button } from "../../components/ui/button";
import { IconButton } from "../../components/ui/icon-button";
import Input from "../../components/ui/input";
import FormInput, {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import { Pressable } from "../../components/ui/pressable";
import Seperator from "../../components/ui/seperator";
import { SvgView } from "../../components/ui/svg";
@@ -183,27 +193,60 @@ export const MFASetup = ({
}: MFAStepProps) => {
const { colors } = useThemeColors();
const user = useUserStore((state) => state.user);
const methodId = method?.id;
const formRef = useRef(
createFormRef({
target: "",
code: ""
})
);
const targetInputRef = useRef<TextInput>(null);
const codeInputRef = useRef<TextInput>(null);
const [authenticatorDetails, setAuthenticatorDetails] = useState({
sharedKey: null,
authenticatorUri: null
});
const code = useRef<string>(undefined);
const phoneNumber = useRef<string>(undefined);
const { seconds, setId, start } = useTimer(method?.id);
const [loading, setLoading] = useState(method?.id === "app" ? true : false);
const [enabling, setEnabling] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState(false);
const [generalError, setGeneralError] = useState<string>();
useEffect(() => {
if (method?.id === "app") {
db.mfa?.setup("app").then((data) => {
setAuthenticatorDetails(data);
setLoading(false);
});
if (methodId === "app") {
setLoading(true);
db.mfa
?.setup("app")
.then((data) => {
setAuthenticatorDetails(data);
setLoading(false);
})
.catch((error: Error) => {
setLoading(false);
setGeneralError(error.message);
});
return;
}
}, [method?.id]);
setLoading(false);
}, [methodId]);
useEffect(() => {
if (!methodId) return;
formRef.current.clearErrors();
formRef.current.setValue(
"target",
methodId === "email"
? user?.email || ""
: methodId === "app"
? authenticatorDetails.sharedKey || ""
: formRef.current.getValue("target")
);
formRef.current.setValue("code", "");
setGeneralError(undefined);
}, [authenticatorDetails.sharedKey, methodId, user?.email]);
const codeHelpText = {
app: "After putting the above code in authenticator app, the app will display a code that you can enter below.",
@@ -212,15 +255,43 @@ export const MFASetup = ({
"You will receive a 2FA code on your email address which you can enter below"
};
const targetValidators =
method?.id === "sms"
? [
validators.required(strings.phoneNumberNotEntered()),
(value: string) =>
isMobilePhone(value, "any", {
strictMode: true
})
? undefined
: strings.enterValidPhone()
]
: method?.id === "email"
? [
validators.required(strings.emailRequired()),
validators.email(strings.enterValidEmail())
]
: [];
const codeValidators = [
validators.required(strings.enterSixDigitCode()),
(value: string) =>
/^\d{6}$/.test(value.trim()) ? undefined : strings.enterSixDigitCode()
];
const onNext = async () => {
if (!code.current || code.current.length !== 6) return;
if (formRef.current.validateField("code")) return;
try {
if (!method) return;
const code = formRef.current.getValue("code").trim();
setGeneralError(undefined);
setEnabling(true);
if (recovery) {
await db.mfa.enableFallback(method.id, code.current);
await db.mfa.enableFallback(method.id, code);
} else {
await db.mfa.enable(method.id, code.current);
await db.mfa.enable(method.id, code);
}
const user = await db.user.fetchUser();
@@ -229,14 +300,18 @@ export const MFASetup = ({
setEnabling(false);
} catch (e) {
const error = e as Error;
ToastManager.error(error, "Error submitting 2fa code");
formRef.current.setError("code", error.message);
setEnabling(false);
}
};
const onSendCode = async () => {
if (error) return;
if (!method || sending) return;
if (method.id !== "app" && formRef.current.validateField("target")) {
return;
}
if (method.id === "app" && authenticatorDetails.sharedKey) {
Clipboard.setString(authenticatorDetails.sharedKey);
if (authenticatorDetails.authenticatorUri) {
@@ -254,30 +329,37 @@ export const MFASetup = ({
}
try {
if (seconds) throw new Error(strings.resendCodeWait());
if (method.id === "sms" && !phoneNumber.current)
throw new Error(strings.phoneNumberNotEntered());
const target = formRef.current.getValue("target").trim();
setGeneralError(undefined);
if (seconds) {
setGeneralError(strings.resendCodeWait());
return;
}
setSending(true);
await db.mfa.setup(method?.id, phoneNumber.current);
await db.mfa.setup(method.id, method.id === "sms" ? target : undefined);
if (method.id === "sms") {
setId(method.id + phoneNumber.current);
setId(method.id + target);
}
await sleep(300);
start(
60,
method.id === "sms" ? method.id + phoneNumber.current : method.id
);
start(60, method.id === "sms" ? method.id + target : method.id);
setSending(false);
ToastManager.show({
heading: strings["2faCodeSentVia"](method.id),
type: "success",
context: "local"
});
codeInputRef.current?.focus();
} catch (e) {
setSending(false);
const error = e as Error;
ToastManager.error(error, strings.errorSend2fa());
if (method.id === "sms" || method.id === "email") {
formRef.current.setError("target", error.message);
} else {
setGeneralError(error.message);
}
}
};
@@ -316,55 +398,54 @@ export const MFASetup = ({
</View>
) : (
<>
<Input
loading={method?.id !== "sms" ? true : false}
value={
method?.id === "email"
? user?.email
: method?.id === "app"
? authenticatorDetails?.sharedKey || ""
: undefined
<FormInput
key={`${method.id}-${authenticatorDetails.sharedKey || user?.email || ""}`}
name="target"
formRef={formRef}
fwdRef={targetInputRef}
loading={method?.id !== "sms"}
editable={method.id === "sms"}
defaultValue={
method.id === "email"
? user?.email || ""
: method.id === "app"
? authenticatorDetails.sharedKey || ""
: undefined
}
multiline={method.id === "app"}
onChangeText={(value) => {
phoneNumber.current = value;
onChangeText={() => {
setGeneralError(undefined);
}}
placeholder={
method?.id === "email"
method.id === "email"
? strings.enterEmailAddress()
: "+1234567890"
}
onSubmit={() => {
onSendCode();
}}
onErrorCheck={(e) => setError(e)}
validationType={method?.id === "email" ? "email" : "phonenumber"}
onSubmitEditing={onSendCode}
validators={targetValidators}
keyboardType={
method.id == "email" ? "email-address" : "phone-pad"
}
errorMessage={
method?.id === "email"
? strings.enterValidEmail()
: strings.enterValidPhone()
method.id === "email" ? "email-address" : "phone-pad"
}
buttons={
error ? null : (
<Button
onPress={onSendCode}
loading={sending}
title={
sending
? null
: method.id === "app"
<Button
onPress={onSendCode}
loading={sending}
style={{
paddingVertical: 0,
paddingHorizontal: 0
}}
title={
sending
? null
: method.id === "app"
? strings.copy()
: `${
seconds
? strings.resendCode(seconds as number)
: strings.sendCode()
}`
}
/>
)
}
/>
}
/>
@@ -373,13 +454,22 @@ export const MFASetup = ({
</Heading>
<Paragraph>{codeHelpText[method?.id]}</Paragraph>
<Seperator />
<Input
<FormInput
name="code"
formRef={formRef}
fwdRef={codeInputRef}
placeholder="xxxxxx"
maxLength={6}
loading={loading}
textAlign="center"
keyboardType="numeric"
onChangeText={(value) => (code.current = value)}
onChangeText={() => {
setGeneralError(undefined);
}}
onSubmitEditing={onNext}
returnKeyLabel={strings.next()}
returnKeyType="done"
validators={codeValidators}
inputStyle={{
fontSize: AppFontSize.lg,
height: 60,
@@ -392,7 +482,25 @@ export const MFASetup = ({
borderWidth: 0,
width: undefined
}}
errorStyle={{
textAlign: "center"
}}
/>
{generalError ? (
<Paragraph
size={AppFontSize.sm}
style={{
color: colors.error.icon,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
textAlign: "center",
width: "100%"
}}
>
{generalError}
</Paragraph>
) : null}
<Seperator />
<Button
title={enabling ? null : strings.next()}
@@ -517,7 +625,7 @@ export const MFARecoveryCodes = ({
ToastManager.show({
heading: strings.codesCopied(),
type: "success",
context: "global"
context: "local"
});
}}
style={{

View File

@@ -16,15 +16,20 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { db } from "../../../common/database";
import { eSendEvent, ToastManager } from "../../../services/event-manager";
import { eUserLoggedIn } from "../../../utils/events";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../../utils/styles";
import Input from "../../../components/ui/input";
import { Button } from "../../../components/ui/button";
import FormInput, {
createFormRef,
validators
} from "../../../components/ui/input/form-input";
import { eSendEvent, ToastManager } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { eUserLoggedIn } from "../../../utils/events";
import { DefaultAppStyles } from "../../../utils/styles";
enum EmailChangeSteps {
verify,
@@ -32,14 +37,16 @@ enum EmailChangeSteps {
}
export const ChangeEmail = () => {
const { colors } = useThemeColors();
const [step, setStep] = useState(EmailChangeSteps.verify);
const emailChangeData = useRef<{
email?: string;
password?: string;
code?: string;
}>({});
const formRef = useRef(
createFormRef({
email: "",
password: "",
code: ""
})
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const emailInputRef = useRef<TextInput>(null);
const passInputRef = useRef<TextInput>(null);
const codeInputRef = useRef<TextInput>(null);
@@ -47,44 +54,52 @@ export const ChangeEmail = () => {
const onSubmit = async () => {
try {
if (step === EmailChangeSteps.verify) {
if (
!emailChangeData.current.email ||
!emailChangeData.current.password ||
error
)
return;
const hasEmailError = formRef.current.validateField("email");
const hasPasswordError = formRef.current.validateField("password");
if (hasEmailError || hasPasswordError) return;
const { email, password } = formRef.current.getValues();
setLoading(true);
const verified = await db.user?.verifyPassword(
emailChangeData.current.password
);
const verified = await db.user?.verifyPassword(password);
if (!verified) throw new Error(strings.passwordIncorrect());
await db.user?.sendVerificationEmail(emailChangeData.current.email);
await db.user?.sendVerificationEmail(email);
setStep(EmailChangeSteps.changeEmail);
formRef.current.clearErrors();
formRef.current.setValue("code", "");
setLoading(false);
} else {
if (
!emailChangeData.current.email ||
!emailChangeData.current.password ||
error ||
!emailChangeData.current.code
)
return;
await db.user?.changeEmail(
emailChangeData.current.email,
emailChangeData.current.password,
emailChangeData.current.code
);
const hasCodeError = formRef.current.validateField("code");
if (hasCodeError) return;
const { email, password, code } = formRef.current.getValues();
setLoading(true);
await db.user?.changeEmail(email, password, code);
eSendEvent(eUserLoggedIn);
close?.();
ToastManager.show({
heading: strings.emailUpdated(emailChangeData.current.email),
heading: strings.emailUpdated(email),
type: "success",
context: "global"
});
Navigation.goBack();
setLoading(false);
}
} catch (e) {
setLoading(false);
ToastManager.error(e as Error);
const error = e as Error;
if (step === EmailChangeSteps.verify) {
if (error.message === strings.passwordIncorrect()) {
formRef.current.setError("password", error.message);
} else {
formRef.current.setError("email", error.message);
}
return;
} else {
formRef.current.setError("code", error.message);
return;
}
}
};
@@ -97,34 +112,54 @@ export const ChangeEmail = () => {
>
{step === EmailChangeSteps.verify ? (
<>
<Input
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
placeholder={strings.enterNewEmail()}
validationType="email"
onErrorCheck={(e) => setError(e)}
onChangeText={(email) => {
emailChangeData.current.email = email;
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterValidEmail())
]}
onSubmitEditing={() => {
passInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passInputRef}
placeholder={strings.enterAccountPassword()}
secureTextEntry
onChangeText={(pass) => {
emailChangeData.current.password = pass;
}}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password"
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={onSubmit}
/>
</>
) : (
<>
<Input
key="code-input"
<FormInput
name="code"
formRef={formRef}
fwdRef={codeInputRef}
placeholder={strings.code()}
defaultValue=""
onChangeText={(code) => {
emailChangeData.current.code = code;
}}
keyboardType="number-pad"
autoCapitalize="none"
autoCorrect={false}
maxLength={6}
validators={[
validators.required(strings.enterSixDigitCode()),
(value: string) =>
/^\d{6}$/.test(value.trim())
? undefined
: strings.enterSixDigitCode()
]}
onSubmitEditing={onSubmit}
/>
</>
)}
@@ -135,8 +170,8 @@ export const ChangeEmail = () => {
loading
? undefined
: step === EmailChangeSteps.verify
? strings.verify()
: strings.changeEmail()
? strings.verify()
: strings.changeEmail()
}
type="accent"
style={{

View File

@@ -47,6 +47,14 @@ export async function verifyUser(
negativeText: closeText || strings.cancel(),
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const user = await db.user.getUser();
let verified = !user ? true : await db.user.verifyPassword(value);
if (verified) {
@@ -95,6 +103,14 @@ export async function verifyUserWithApplock() {
keyboardType: keyboardType,
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const verified = await validateAppLockPassword(value);
if (!verified) {
ToastManager.show({

View File

@@ -529,6 +529,14 @@ export const settingsGroups: SettingSection[] = [
positiveText: strings.delete(),
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const verified = await db.user?.verifyPassword(value);
if (verified) {
setTimeout(async () => {

View File

@@ -201,6 +201,14 @@ const SettingsUserSection = ({ item }) => {
inputPlaceholder: strings.enterFullName(),
defaultValue: userProfile?.fullName,
positivePress: async (value) => {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.nameIsRequired()),
undefined,
"local"
);
return;
}
db.settings
.setProfile({
fullName: value

View File

@@ -65,6 +65,14 @@ export async function unlockVault({
paragraph: paragraph,
inputPlaceholder: strings.enterPassword(),
positivePress: async (value) => {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const unlocked = await db.vault.unlock(value);
if (!unlocked) {
ToastManager.show({

View File

@@ -47,6 +47,7 @@ import { FlexScrollContainer } from "../../components/scroll-container";
import { useCallback, useEffect, useRef, useState } from "react";
import {
DropdownSettingComponent,
Section,
SectionGroup,
SectionKeys,
Setting,
@@ -254,9 +255,15 @@ export const SettingsDialog = DialogManager.register(function SettingsDialog(
overflow: "auto"
}}
>
{activeSettings.map((group) => (
<SettingsGroupComponent item={group} />
))}
{activeSettings.length > 0 ? (
activeSettings.map((group) => (
<SettingsGroupComponent item={group} />
))
) : (
<Text variant="body" sx={{ color: "paragraph-secondary" }}>
{strings.noResultsFound()}
</Text>
)}
</FlexScrollContainer>
</Flex>
</Dialog>
@@ -317,12 +324,17 @@ function SettingsSideBar(props: SettingsSideBarProps) {
SettingsGroups.filter((g) => g.section === route)
);
const groups: SettingsGroup[] = [];
let groups: SettingsGroup[] = [];
for (const group of SettingsGroups) {
const section = findSection(group.section);
if (section?.isHidden?.() || group.isHidden?.()) continue;
const isTitleMatch =
typeof group.header === "string" &&
group.header.toLowerCase().includes(query);
const isSectionMatch = group.section.includes(query);
const isSectionMatch = group.section
.toLowerCase()
.includes(query);
if (isTitleMatch || isSectionMatch) {
groups.push(group);
@@ -347,6 +359,18 @@ function SettingsSideBar(props: SettingsSideBarProps) {
if (!settings.length) continue;
groups.push({ ...group, settings });
}
const matchedSections = findSections(query);
if (matchedSections.length > 0) {
const matchedGroups = SettingsGroups.filter((g) =>
matchedSections.some((s) => s.key === g.section)
);
// remove groups whose sections were matched to avoid duplicate
// entries.
groups = groups.filter(
(g) => !matchedGroups.some((mg) => mg.section === g.section)
);
groups.push(...matchedGroups);
}
onNavigate(groups);
}}
/>
@@ -717,3 +741,22 @@ function NumberInput({
</Flex>
);
}
function findSection(key: SectionKeys) {
for (const group of sectionGroups) {
const section = group.sections.find((s) => s.key === key);
if (section) return section;
}
return null;
}
function findSections(query: string) {
const sections: Section[] = [];
for (const group of sectionGroups) {
for (const section of group.sections) {
if (section.isHidden?.()) continue;
if (section.title.toLowerCase().includes(query)) sections.push(section);
}
}
return sections;
}

View File

@@ -548,18 +548,24 @@ class UserManager {
const email = newEmail.toLowerCase();
await http.patch(
`${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
{
type: "change_email",
new_email: newEmail,
password: await this.db.storage().hash(password, email, {
usesFallback: await this.usesFallbackPWHash(password)
}),
verification_code: code
},
token
);
try {
await http.patch(
`${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
{
type: "change_email",
new_email: newEmail,
password: await this.db.storage().hash(password, email, {
usesFallback: await this.usesFallbackPWHash(password)
}),
verification_code: code
},
token
);
} catch (e) {
const error = e as Error;
if (error.message === "Invalid token.") throw new Error("Invalid code.");
throw error;
}
}
recoverAccount(email: string) {

View File

@@ -1,6 +1,6 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-04-20 11:33+0500\n"
"POT-Creation-Date: 2026-05-08 10:43+0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -1968,10 +1968,15 @@ msgstr "Curate the toolbar that fits your needs and matches your personality."
msgid "Current note"
msgstr "Current note"
#: src/strings.ts:1479
#: src/strings.ts:1485
msgid "Current password"
msgstr "Current password"
#: src/strings.ts:2670
msgid "Current password required"
msgstr "Current password required"
#: src/strings.ts:1229
msgid "Current path: {path}"
msgstr "Current path: {path}"
@@ -4034,6 +4039,10 @@ msgstr "Multi-layer encryption to most important notes"
msgid "Name"
msgstr "Name"
#: src/strings.ts:2669
msgid "Name is required."
msgstr "Name is required."
#: src/strings.ts:1688
msgid "Native high-performance encryption"
msgstr "Native high-performance encryption"
@@ -4403,10 +4412,6 @@ msgstr "Okay"
msgid "Old - new"
msgstr "Old - new"
#: src/strings.ts:1479
msgid "Old password"
msgstr "Old password"
#: src/strings.ts:1835
msgid "Older version"
msgstr "Older version"

View File

@@ -1,6 +1,6 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-04-20 11:33+0500\n"
"POT-Creation-Date: 2026-05-08 10:43+0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -1957,10 +1957,15 @@ msgstr ""
msgid "Current note"
msgstr ""
#: src/strings.ts:1479
#: src/strings.ts:1485
msgid "Current password"
msgstr ""
#: src/strings.ts:2670
msgid "Current password required"
msgstr ""
#: src/strings.ts:1229
msgid "Current path: {path}"
msgstr ""
@@ -4014,6 +4019,10 @@ msgstr ""
msgid "Name"
msgstr ""
#: src/strings.ts:2669
msgid "Name is required."
msgstr ""
#: src/strings.ts:1688
msgid "Native high-performance encryption"
msgstr ""
@@ -4377,10 +4386,6 @@ msgstr ""
msgid "Old - new"
msgstr ""
#: src/strings.ts:1479
msgid "Old password"
msgstr ""
#: src/strings.ts:1835
msgid "Older version"
msgstr ""

View File

@@ -1476,7 +1476,7 @@ $day$: Current day (eg. Monday)`,
someNotesPublished: () => t`Some notes are published`,
unpublishToDelete: () => t`Unpublish notes to delete them`,
filterAttachments: () => t`Filter attachments by filename, type or hash`,
oldPassword: () => t`Old password`,
oldPassword: () => t`Current password`,
newPassword: () => t`New password`,
email: () => t`Email`,
emailInvalid: () => t`Invalid email`,
@@ -2665,5 +2665,7 @@ Use this if changes from other devices are not appearing on this device. This wi
deleteAttachmentConfirm: () =>
t`Are you sure you want to delete this attachment?`,
attachmentDeleted: () => t`Attachment deleted`,
titleIsRequired: () => t`Title is required`
titleIsRequired: () => t`Title is required`,
nameIsRequired: () => t`Name is required.`,
currentPasswordRequired: () => t`Current password required`
};