summaryrefslogtreecommitdiff
path: root/app/(auth)/reset-password.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/(auth)/reset-password.tsx')
-rw-r--r--app/(auth)/reset-password.tsx267
1 files changed, 267 insertions, 0 deletions
diff --git a/app/(auth)/reset-password.tsx b/app/(auth)/reset-password.tsx
new file mode 100644
index 0000000..a5f8388
--- /dev/null
+++ b/app/(auth)/reset-password.tsx
@@ -0,0 +1,267 @@
+import { Ionicons } from "@expo/vector-icons";
+import { router, useLocalSearchParams } from "expo-router";
+import { useEffect, useMemo, useState } from "react";
+import {
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { apiClient } from "../../api/client";
+import { t } from "../../lib/locales";
+import { colors } from "../../lib/theme";
+import { Button, H1, Muted, TextInput } from "../../lib/ui";
+
+type Mode = "request" | "confirm";
+
+export default function ResetPassword() {
+ const params = useLocalSearchParams<{ token?: string | string[] }>();
+ const tokenFromParams = useMemo(
+ () => (Array.isArray(params.token) ? params.token[0] : params.token) || "",
+ [params.token],
+ );
+ const [mode, setMode] = useState<Mode>(tokenFromParams ? "confirm" : "request");
+ const [email, setEmail] = useState("");
+ const [token, setToken] = useState(tokenFromParams);
+ const [password, setPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+ const [emailError, setEmailError] = useState("");
+ const [tokenError, setTokenError] = useState("");
+ const [passwordError, setPasswordError] = useState("");
+ const [confirmPasswordError, setConfirmPasswordError] = useState("");
+
+ useEffect(() => {
+ if (!tokenFromParams) return;
+ setToken(tokenFromParams);
+ setMode("confirm");
+ }, [tokenFromParams]);
+
+ const resetFieldErrors = () => {
+ setEmailError("");
+ setTokenError("");
+ setPasswordError("");
+ setConfirmPasswordError("");
+ };
+
+ const validateRequest = () => {
+ resetFieldErrors();
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ setEmailError(t("invalidEmail"));
+ return false;
+ }
+
+ return true;
+ };
+
+ const validateConfirm = () => {
+ resetFieldErrors();
+ let valid = true;
+
+ if (!token.trim()) {
+ setTokenError(t("resetTokenRequired"));
+ valid = false;
+ }
+
+ if (!password) {
+ setPasswordError(t("passwordRequired"));
+ valid = false;
+ } else if (password.length < 8) {
+ setPasswordError(t("passwordTooShort"));
+ valid = false;
+ }
+
+ if (password !== confirmPassword) {
+ setConfirmPasswordError(t("passwordsDoNotMatch"));
+ valid = false;
+ }
+
+ return valid;
+ };
+
+ const handleRequestReset = async () => {
+ if (!validateRequest()) return;
+
+ setLoading(true);
+ setError("");
+ setSuccess("");
+
+ try {
+ const result = await apiClient.requestPasswordReset(email.trim());
+ if (!result.success) {
+ setError(result.reason || t("resetPasswordError"));
+ return;
+ }
+
+ setSuccess(t("passwordResetEmailSent"));
+ } catch {
+ setError(t("resetPasswordError"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleConfirmReset = async () => {
+ if (!validateConfirm()) return;
+
+ setLoading(true);
+ setError("");
+ setSuccess("");
+
+ try {
+ const result = await apiClient.confirmPasswordReset(token.trim(), password);
+ if (!result.success) {
+ setError(result.reason || t("resetPasswordError"));
+ return;
+ }
+
+ setSuccess(t("passwordResetSuccess"));
+ setTimeout(() => router.replace("/(auth)/signin"), 500);
+ } catch {
+ setError(t("resetPasswordError"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const switchMode = () => {
+ resetFieldErrors();
+ setError("");
+ setSuccess("");
+ setMode((current) => (current === "request" ? "confirm" : "request"));
+ };
+
+ return (
+ <SafeAreaView style={styles.container}>
+ <KeyboardAvoidingView
+ style={styles.keyboardView}
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
+ >
+ <ScrollView
+ contentContainerStyle={styles.scrollContent}
+ keyboardShouldPersistTaps="handled"
+ >
+ <TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
+ <Ionicons name="arrow-back" size={24} color={colors.onBackground} />
+ </TouchableOpacity>
+
+ <View style={styles.header}>
+ <H1>{t("resetPassword")}</H1>
+ <Muted>{t("resetPasswordHelp")}</Muted>
+ </View>
+
+ <View style={styles.form}>
+ {mode === "request" ? (
+ <TextInput
+ label={t("email")}
+ value={email}
+ onChangeText={setEmail}
+ placeholder={t("emailPlaceholder")}
+ keyboardType="email-address"
+ autoCapitalize="none"
+ error={emailError}
+ />
+ ) : (
+ <>
+ <TextInput
+ label={t("resetPasswordToken")}
+ value={token}
+ onChangeText={setToken}
+ placeholder={t("resetPasswordTokenPlaceholder")}
+ error={tokenError}
+ />
+
+ <TextInput
+ label={t("newPassword")}
+ value={password}
+ onChangeText={setPassword}
+ placeholder={t("newPasswordPlaceholder")}
+ secureTextEntry
+ error={passwordError}
+ />
+
+ <TextInput
+ label={t("confirmNewPassword")}
+ value={confirmPassword}
+ onChangeText={setConfirmPassword}
+ placeholder={t("confirmPasswordPlaceholder")}
+ secureTextEntry
+ error={confirmPasswordError}
+ />
+ </>
+ )}
+
+ {error ? <Text style={styles.error}>{error}</Text> : null}
+ {success ? <Text style={styles.success}>{success}</Text> : null}
+
+ <Button
+ title={mode === "request" ? t("sendResetLink") : t("resetPassword")}
+ onPress={mode === "request" ? handleRequestReset : handleConfirmReset}
+ loading={loading}
+ disabled={loading}
+ />
+
+ <Button
+ title={
+ mode === "request"
+ ? t("switchToResetWithToken")
+ : t("switchToRequestReset")
+ }
+ variant="secondary"
+ onPress={switchMode}
+ disabled={loading}
+ />
+
+ <Button
+ title={t("backToSignIn")}
+ variant="text"
+ onPress={() => router.replace("/(auth)/signin")}
+ disabled={loading}
+ />
+ </View>
+ </ScrollView>
+ </KeyboardAvoidingView>
+ </SafeAreaView>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ keyboardView: {
+ flex: 1,
+ },
+ scrollContent: {
+ flexGrow: 1,
+ padding: 24,
+ },
+ backButton: {
+ marginBottom: 16,
+ },
+ header: {
+ marginBottom: 32,
+ gap: 8,
+ },
+ form: {
+ gap: 20,
+ },
+ error: {
+ color: colors.primary,
+ fontSize: 14,
+ textAlign: "center",
+ },
+ success: {
+ color: colors.onBackground,
+ fontSize: 14,
+ textAlign: "center",
+ },
+});