summaryrefslogtreecommitdiff
path: root/app/(auth)
diff options
context:
space:
mode:
Diffstat (limited to 'app/(auth)')
-rw-r--r--app/(auth)/_layout.tsx1
-rw-r--r--app/(auth)/reset-password.tsx267
-rw-r--r--app/(auth)/signin.tsx40
-rw-r--r--app/(auth)/signup.tsx43
4 files changed, 340 insertions, 11 deletions
diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx
index e18b629..3d5e604 100644
--- a/app/(auth)/_layout.tsx
+++ b/app/(auth)/_layout.tsx
@@ -24,6 +24,7 @@ export default function AuthLayout() {
<Stack.Screen name="welcome" />
<Stack.Screen name="signin" />
<Stack.Screen name="signup" />
+ <Stack.Screen name="reset-password" />
</Stack>
);
}
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",
+ },
+});
diff --git a/app/(auth)/signin.tsx b/app/(auth)/signin.tsx
index 1eac63b..e2d0eda 100644
--- a/app/(auth)/signin.tsx
+++ b/app/(auth)/signin.tsx
@@ -12,12 +12,18 @@ import {
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useAuth } from "../../lib/auth";
+import { useGoogleAuth } from "../../lib/google-auth";
import { t } from "../../lib/locales";
import { colors } from "../../lib/theme";
import { Button, H1, Muted, TextInput } from "../../lib/ui";
export default function SignIn() {
const { signIn } = useAuth();
+ const {
+ continueWithGoogle,
+ isLoading: googleLoading,
+ error: googleError,
+ } = useGoogleAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
@@ -55,7 +61,7 @@ export default function SignIn() {
if (!result.success) {
setError(result.reason || t("signInError"));
}
- } catch (e) {
+ } catch {
setError(t("signInError"));
} finally {
setLoading(false);
@@ -103,14 +109,36 @@ export default function SignIn() {
error={passwordError}
/>
+ <TouchableOpacity
+ onPress={() => router.push("/(auth)/reset-password")}
+ style={styles.forgotPasswordLinkContainer}
+ >
+ <Text style={styles.link}>{t("forgotPassword")}</Text>
+ </TouchableOpacity>
+
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={t("signIn")}
onPress={handleSignIn}
loading={loading}
- disabled={loading}
+ disabled={loading || googleLoading}
+ />
+
+ <Button
+ title={t("continueWithGoogle")}
+ variant="secondary"
+ onPress={continueWithGoogle}
+ loading={googleLoading}
+ disabled={loading || googleLoading}
/>
+ <Muted style={styles.googleHelp}>
+ {t("googleAuthUnifiedHint")}
+ </Muted>
+
+ {googleError ? (
+ <Text style={styles.error}>{googleError}</Text>
+ ) : null}
</View>
<View style={styles.footer}>
@@ -151,6 +179,10 @@ const styles = StyleSheet.create({
fontSize: 14,
textAlign: "center",
},
+ googleHelp: {
+ textAlign: "center",
+ paddingHorizontal: 8,
+ },
footer: {
flexDirection: "row",
justifyContent: "center",
@@ -162,4 +194,8 @@ const styles = StyleSheet.create({
color: colors.primary,
fontWeight: "700",
},
+ forgotPasswordLinkContainer: {
+ alignItems: "flex-end",
+ marginTop: -8,
+ },
});
diff --git a/app/(auth)/signup.tsx b/app/(auth)/signup.tsx
index 127ea9f..eded0b6 100644
--- a/app/(auth)/signup.tsx
+++ b/app/(auth)/signup.tsx
@@ -2,22 +2,28 @@ import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useState } from "react";
import {
- KeyboardAvoidingView,
- Platform,
- ScrollView,
- StyleSheet,
- Text,
- TouchableOpacity,
- View,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useAuth } from "../../lib/auth";
+import { useGoogleAuth } from "../../lib/google-auth";
import { t } from "../../lib/locales";
import { colors } from "../../lib/theme";
import { Button, H1, Muted, TextInput } from "../../lib/ui";
export default function SignUp() {
const { signUp } = useAuth();
+ const {
+ continueWithGoogle,
+ isLoading: googleLoading,
+ error: googleError,
+ } = useGoogleAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -66,7 +72,7 @@ export default function SignUp() {
if (!result.success) {
setError(result.reason || t("signUpError"));
}
- } catch (e) {
+ } catch {
setError(t("signUpError"));
} finally {
setLoading(false);
@@ -129,8 +135,23 @@ export default function SignUp() {
title={t("signUp")}
onPress={handleSignUp}
loading={loading}
- disabled={loading}
+ disabled={loading || googleLoading}
/>
+
+ <Button
+ title={t("continueWithGoogle")}
+ variant="secondary"
+ onPress={continueWithGoogle}
+ loading={googleLoading}
+ disabled={loading || googleLoading}
+ />
+ <Muted style={styles.googleHelp}>
+ {t("googleAuthUnifiedHint")}
+ </Muted>
+
+ {googleError ? (
+ <Text style={styles.error}>{googleError}</Text>
+ ) : null}
</View>
<View style={styles.footer}>
@@ -171,6 +192,10 @@ const styles = StyleSheet.create({
fontSize: 14,
textAlign: "center",
},
+ googleHelp: {
+ textAlign: "center",
+ paddingHorizontal: 8,
+ },
footer: {
flexDirection: "row",
justifyContent: "center",