fix(native): explain change-password failures and block double submit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-05 22:15:57 -04:00
1 parent ea9cb3de8d
commit 085b681d8a
15 files changed
+195 -131

No files matched your search

+140 -131
View File
@@ -3,6 +3,7 @@ import { useForm } from "@tanstack/react-form";
import { Stack, useRouter } from "expo-router";
import { useMemo, useRef, useState } from "react";
import { Alert, ScrollView, type TextInput, View } from "react-native";
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
import Animated, { FadeInDown } from "react-native-reanimated";
import { useResolveClassNames } from "uniwind";
import { z } from "zod";
@@ -12,6 +13,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Text } from "@/components/ui/text";
import { FieldError, Input, Label, TextField } from "@/components/ui/text-field";
import { getAuthErrorMessage } from "@/lib/error-messages";
import { authClient } from "@/lib/server";
import { toast } from "@/lib/toast";
import * as Haptics from "@/utils/haptics";
@@ -68,7 +70,7 @@ export default function ChangePasswordScreen() {
});
if (result.error) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert(t`Error`, t`Failed to change password`);
Alert.alert(t`Error`, getAuthErrorMessage(result.error, t`Failed to change password`));
return;
}
@@ -83,144 +85,151 @@ export default function ChangePasswordScreen() {
},
});
const submit = () => {
if (!form.state.isSubmitting) void form.handleSubmit();
};
return (
<ScrollView
className="bg-background flex-1"
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 24,
}}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
// iOS's form sheet already moves above the keyboard; only Android needs padding.
<KeyboardAvoidingView
behavior="padding"
enabled={process.env.EXPO_OS === "android"}
style={{ flex: 1 }}
>
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
<Trans>Change password</Trans>
</Stack.Screen.Title>
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
validationError: formatFormErrors(state.errorMap.onSubmit),
})}
<ScrollView
className="bg-background flex-1"
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 24,
}}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
>
{({ isSubmitting, validationError }) => (
<View className="gap-4">
{validationError && (
<FieldError isInvalid className="mb-1">
{validationError}
</FieldError>
)}
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
<Trans>Change password</Trans>
</Stack.Screen.Title>
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<form.Field name="currentPassword">
{(field) => (
<TextField>
<Label>
<Trans>Current password</Trans>
</Label>
<Input
value={field.state.value}
accessibilityLabel={t`Current password`}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="current-password"
textContentType="password"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => newPasswordRef.current?.focus()}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
validationError: formatFormErrors(state.errorMap.onSubmit),
})}
>
{({ isSubmitting, validationError }) => (
<View className="gap-4">
{validationError && (
<FieldError isInvalid className="mb-1">
{validationError}
</FieldError>
)}
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<form.Field name="newPassword">
{(field) => (
<TextField>
<Label>
<Trans>New password</Trans>
</Label>
<Input
ref={newPasswordRef}
value={field.state.value}
accessibilityLabel={t`New password`}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => confirmPasswordRef.current?.focus()}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<form.Field name="currentPassword">
{(field) => (
<TextField>
<Label>
<Trans>Current password</Trans>
</Label>
<Input
value={field.state.value}
accessibilityLabel={t`Current password`}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="current-password"
textContentType="password"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => newPasswordRef.current?.focus()}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
<form.Field name="confirmPassword">
{(field) => (
<TextField>
<Label>
<Trans>Confirm new password</Trans>
</Label>
<Input
ref={confirmPasswordRef}
value={field.state.value}
accessibilityLabel={t`Confirm new password`}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="go"
onSubmitEditing={form.handleSubmit}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<form.Field name="newPassword">
{(field) => (
<TextField>
<Label>
<Trans>New password</Trans>
</Label>
<Input
ref={newPasswordRef}
value={field.state.value}
accessibilityLabel={t`New password`}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => confirmPasswordRef.current?.focus()}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="border-border bg-input flex-row items-center justify-between rounded-xl border px-3.5 py-3"
style={{ borderCurve: "continuous" }}
>
<Text className="text-foreground text-base">
<Trans>Sign out of other sessions</Trans>
</Text>
<Switch
value={revokeOtherSessions}
onValueChange={setRevokeOtherSessions}
accessibilityLabel={t`Sign out of other sessions`}
/>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
<form.Field name="confirmPassword">
{(field) => (
<TextField>
<Label>
<Trans>Confirm new password</Trans>
</Label>
<Input
ref={confirmPasswordRef}
value={field.state.value}
accessibilityLabel={t`Confirm new password`}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="go"
onSubmitEditing={submit}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(500)}>
<Button
onPress={form.handleSubmit}
disabled={isSubmitting}
className="bg-primary mt-2"
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="border-border bg-input flex-row items-center justify-between rounded-xl border px-3.5 py-3"
style={{ borderCurve: "continuous" }}
>
{isSubmitting ? (
<Spinner size="sm" />
) : (
<ButtonLabel>
<Trans>Update password</Trans>
</ButtonLabel>
)}
</Button>
</Animated.View>
</View>
)}
</form.Subscribe>
</ScrollView>
<Text className="text-foreground text-base">
<Trans>Sign out of other sessions</Trans>
</Text>
<Switch
value={revokeOtherSessions}
onValueChange={setRevokeOtherSessions}
accessibilityLabel={t`Sign out of other sessions`}
/>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(500)}>
<Button onPress={submit} disabled={isSubmitting} className="bg-primary mt-2">
{isSubmitting ? (
<Spinner size="sm" />
) : (
<ButtonLabel>
<Trans>Update password</Trans>
</ButtonLabel>
)}
</Button>
</Animated.View>
</View>
)}
</form.Subscribe>
</ScrollView>
</KeyboardAvoidingView>
);
}
+1
View File
@@ -45,6 +45,7 @@ const AUTH_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
PASSWORD_TOO_SHORT: msg`Password is too short`,
PASSWORD_TOO_LONG: msg`Password is too long`,
INVALID_EMAIL: msg`Enter a valid email address`,
INVALID_PASSWORD: msg`Current password is incorrect`,
REGISTRATION_CLOSED: msg`Registration is currently closed`,
};
+4
View File
@@ -925,6 +925,10 @@ msgstr "جارٍ الإنشاء…"
msgid "Current password"
msgstr "كلمة المرور الحالية"
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr ""
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+4
View File
@@ -925,6 +925,10 @@ msgstr "Erstelle..."
msgid "Current password"
msgstr "Aktuelles Passwort"
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr ""
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+4
View File
@@ -925,6 +925,10 @@ msgstr ""
msgid "Current password"
msgstr ""
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr "Current password is incorrect"
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+4
View File
@@ -925,6 +925,10 @@ msgstr "Creando…"
msgid "Current password"
msgstr "Contraseña actual"
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr ""
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+4
View File
@@ -925,6 +925,10 @@ msgstr "Création…"
msgid "Current password"
msgstr "Mot de passe actuel"
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr ""
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+4
View File
@@ -925,6 +925,10 @@ msgstr "יוצר…"
msgid "Current password"
msgstr "סיסמה נוכחית"
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr ""
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+4
View File
@@ -925,6 +925,10 @@ msgstr "Creazione in corso…"
msgid "Current password"
msgstr "Password attuale"
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr ""
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+4
View File
@@ -925,6 +925,10 @@ msgstr "作成中…"
msgid "Current password"
msgstr "現在のパスワード"
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr ""
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+4
View File
@@ -925,6 +925,10 @@ msgstr "생성 중…"
msgid "Current password"
msgstr "현재 비밀번호"
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr ""
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+4
View File
@@ -925,6 +925,10 @@ msgstr "Aanmaken…"
msgid "Current password"
msgstr "Huidig wachtwoord"
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr ""
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+4
View File
@@ -925,6 +925,10 @@ msgstr "Criando…"
msgid "Current password"
msgstr "Senha atual"
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr ""
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+4
View File
@@ -925,6 +925,10 @@ msgstr "创建中…"
msgid "Current password"
msgstr "当前密码"
#: packages/i18n/src/errors.ts
msgid "Current password is incorrect"
msgstr ""
#: apps/native/src/app/change-password.tsx
#: apps/web/src/components/settings/account-section.tsx
msgid "Current password is required"
+6
View File
@@ -48,4 +48,10 @@ describe("getAuthErrorMessage", () => {
expect(getAuthErrorMessage({ code: "INVALID_EMAIL" }, "F")).toBe("Enter a valid email address");
expect(getAuthErrorMessage({ code: "UNKNOWN" }, "F")).toBe("F");
});
test("maps a wrong current password", () => {
expect(getAuthErrorMessage({ code: "INVALID_PASSWORD" }, "x")).toBe(
"Current password is incorrect",
);
});
});