diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/(auth)/_layout.tsx | 1 | ||||
| -rw-r--r-- | app/(auth)/reset-password.tsx | 267 | ||||
| -rw-r--r-- | app/(auth)/signin.tsx | 40 | ||||
| -rw-r--r-- | app/(auth)/signup.tsx | 43 | ||||
| -rw-r--r-- | app/(tabs)/controls.tsx | 90 |
5 files changed, 421 insertions, 20 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", diff --git a/app/(tabs)/controls.tsx b/app/(tabs)/controls.tsx index ac44f1b..3e41b6a 100644 --- a/app/(tabs)/controls.tsx +++ b/app/(tabs)/controls.tsx @@ -1,6 +1,11 @@ import { useEffect, useState } from "react"; import { Pressable, Switch, View } from "react-native"; -import { ControlsData, getControlsData, updateSafetyControl } from "../../api"; +import { + ControlsData, + SafetyControlValue, + getControlsData, + updateSafetyControl, +} from "../../api"; import { useDevice } from "../../lib/device"; import { t } from "../../lib/locales"; import { colors } from "../../lib/theme"; @@ -18,14 +23,16 @@ import { export default function ControlsScreen() { const [data, setData] = useState<ControlsData | null>(null); - const [state, setState] = useState<Record<string, boolean>>({}); + const [state, setState] = useState<Record<string, SafetyControlValue>>({}); const [communication, setCommunication] = useState<"scan" | "block">("scan"); const [communicationModalVisible, setCommunicationModalVisible] = useState(false); + const [galleryScanningMode, setGalleryScanningMode] = useState< + "delete" | "notify" | "none" + >("none"); + const [galleryScanningModalVisible, setGalleryScanningModalVisible] = + useState(false); const [confirmModalVisible, setConfirmModalVisible] = useState(false); - const [pendingSelection, setPendingSelection] = useState< - "scan" | "block" | null - >(null); const { devices, @@ -34,6 +41,16 @@ export default function ControlsScreen() { isLoading: isDeviceLoading, } = useDevice(); + const normalizeGalleryScanningMode = ( + value: SafetyControlValue | undefined, + ): "delete" | "notify" | "none" => { + if (value === "delete" || value === "notify" || value === "none") { + return value; + } + + return "none"; + }; + useEffect(() => { if (selectedDevice) { getControlsData().then((d) => { @@ -47,15 +64,34 @@ export default function ControlsScreen() { (c) => c.key === "block_strangers", )?.defaultValue; setCommunication(blockDefault ? "block" : "scan"); + const galleryDefault = d.safetyControls.find( + (c) => c.key === "gallery_scanning_mode", + )?.defaultValue; + setGalleryScanningMode(normalizeGalleryScanningMode(galleryDefault)); }); } }, [selectedDevice]); - const handleToggle = (key: string, value: boolean) => { + const handleToggle = (key: string, value: SafetyControlValue) => { setState((prev) => ({ ...prev, [key]: value })); updateSafetyControl(key, value); }; + const handleGalleryScanningModeChange = ( + value: "delete" | "notify" | "none", + ) => { + setGalleryScanningMode(value); + handleToggle("gallery_scanning_mode", value); + setGalleryScanningModalVisible(false); + }; + + const galleryScanningLabel = + galleryScanningMode === "delete" + ? t("galleryScanningDelete") + : galleryScanningMode === "notify" + ? t("galleryScanningNotify") + : t("galleryScanningNone"); + const openCommunicationOptions = () => { setCommunicationModalVisible(true); }; @@ -67,7 +103,6 @@ export default function ControlsScreen() { setCommunicationModalVisible(false); } else { // block chosen - show confirmation - setPendingSelection("block"); setConfirmModalVisible(true); setCommunicationModalVisible(false); } @@ -77,12 +112,10 @@ export default function ControlsScreen() { setCommunication("block"); handleToggle("block_strangers", true); setConfirmModalVisible(false); - setPendingSelection(null); }; const cancelConfirm = () => { setConfirmModalVisible(false); - setPendingSelection(null); }; if (!selectedDevice && !isDeviceLoading) @@ -265,6 +298,45 @@ export default function ControlsScreen() { } /> </Card> + + <Card> + <H2>{t("galleryScanning")}</H2> + <Divider /> + <Row + left={ + <View style={{ gap: 4 }}> + <H2>{t("galleryScanning")}</H2> + <Muted>{t("scanGalleryForInappropriateImages")}</Muted> + </View> + } + right={ + <Pressable onPress={() => setGalleryScanningModalVisible(true)}> + <Muted>{galleryScanningLabel}</Muted> + </Pressable> + } + /> + + <SelectDialog + visible={galleryScanningModalVisible} + title={t("galleryScanningTitle")} + options={[ + { + label: t("galleryScanningNone"), + onPress: () => handleGalleryScanningModeChange("none"), + }, + { + label: t("galleryScanningNotify"), + onPress: () => handleGalleryScanningModeChange("notify"), + }, + { + label: t("galleryScanningDelete"), + onPress: () => handleGalleryScanningModeChange("delete"), + destructive: true, + }, + ]} + onClose={() => setGalleryScanningModalVisible(false)} + /> + </Card> </Screen> ); } |