From 3273e7a0fbbce82f4ce6cacbcdb7b6d6848f6c1b Mon Sep 17 00:00:00 2001 From: JustZvan Date: Mon, 6 Apr 2026 15:32:51 +0200 Subject: feat: gallery scanning preferences --- README.md | 18 +++ api/client.ts | 36 ++++++ api/controls.ts | 10 +- api/types.ts | 4 +- app.json | 3 + app/(auth)/_layout.tsx | 1 + app/(auth)/reset-password.tsx | 267 ++++++++++++++++++++++++++++++++++++++++++ app/(auth)/signin.tsx | 40 ++++++- app/(auth)/signup.tsx | 43 +++++-- app/(tabs)/controls.tsx | 90 ++++++++++++-- lib/auth.tsx | 25 +++- lib/google-auth.ts | 116 ++++++++++++++++++ lib/locales.ts | 63 ++++++++++ package.json | 2 + pnpm-lock.yaml | 48 ++++++++ 15 files changed, 737 insertions(+), 29 deletions(-) create mode 100644 app/(auth)/reset-password.tsx create mode 100644 lib/google-auth.ts diff --git a/README.md b/README.md index 48dd63f..9f3bd8a 100644 --- a/README.md +++ b/README.md @@ -48,3 +48,21 @@ Join our community of developers creating universal apps. - [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. - [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. + +## Google Auth Setup + +This app uses Google ID tokens from Expo Auth Session and sends them to backend endpoint `/signin/google`. + +Set these values in Expo config under `expo.extra` in `app.json` (or override with environment variables): + +- `GOOGLE_IOS_CLIENT_ID` or `EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID` +- `GOOGLE_ANDROID_CLIENT_ID` or `EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID` +- `GOOGLE_WEB_CLIENT_ID` or `EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID` (used for Expo Go / web-based auth session) + +Notes: + +- Use OAuth client IDs from Google Cloud Console. +- Ensure IDs match this app: + - Android package: `sh.lajo.buddyforparents` + - iOS bundle ID: `sh.lajo.buddyforparents` +- The frontend sends `idToken` to backend, and backend handles both existing-user sign-in and automatic account creation. diff --git a/api/client.ts b/api/client.ts index 703b1f0..63aa525 100644 --- a/api/client.ts +++ b/api/client.ts @@ -136,9 +136,45 @@ class ApiClient { return result; } + async signInWithGoogle( + idToken: string, + ): Promise<{ success: boolean; token?: string; reason?: string }> { + const result = await this.post<{ + success: boolean; + token: string; + reason: string; + }>("/signin/google", { + idToken, + }); + + if (result.success && result.token) { + await this.setToken(result.token); + } + + return result; + } + async signOut() { await this.clearToken(); } + + async requestPasswordReset( + email: string, + ): Promise<{ success: boolean; reason: string }> { + return this.post<{ success: boolean; reason: string }>("/resetpassword", { + email, + }); + } + + async confirmPasswordReset( + token: string, + password: string, + ): Promise<{ success: boolean; reason: string }> { + return this.post<{ success: boolean; reason: string }>("/resetpassword", { + token, + password, + }); + } } export const apiClient = new ApiClient(); diff --git a/api/controls.ts b/api/controls.ts index 53db7a1..e8c3b02 100644 --- a/api/controls.ts +++ b/api/controls.ts @@ -1,5 +1,5 @@ import { apiClient } from "./client"; -import { ControlsData, SafetyControl } from "./types"; +import { ControlsData, SafetyControl, SafetyControlValue } from "./types"; export async function getControlsData(): Promise { const deviceId = apiClient.getSelectedDeviceId(); @@ -30,7 +30,7 @@ export async function getControlsData(): Promise { export async function updateSafetyControl( key: string, - value: boolean + value: SafetyControlValue, ): Promise { const deviceId = apiClient.getSelectedDeviceId(); @@ -97,5 +97,11 @@ function getDefaultControls(): SafetyControl[] { description: "Notify when a new contact is added.", defaultValue: true, }, + { + key: "gallery_scanning_mode", + title: "Gallery scanning", + description: "Scan gallery for inappropriate images", + defaultValue: "none", + }, ]; } diff --git a/api/types.ts b/api/types.ts index 7338d4b..424ccce 100644 --- a/api/types.ts +++ b/api/types.ts @@ -41,11 +41,13 @@ export type Device = { lastCheck: string; }; +export type SafetyControlValue = boolean | string; + export type SafetyControl = { key: string; title: string; description: string; - defaultValue: boolean; + defaultValue: SafetyControlValue; }; export type ControlsData = { diff --git a/app.json b/app.json index 3e60a1f..5d5eda8 100644 --- a/app.json +++ b/app.json @@ -17,6 +17,9 @@ }, "extra": { "API_BASE_URL": "https://buddy-dev.justzvan.click", + "GOOGLE_IOS_CLIENT_ID": "YOUR_IOS_GOOGLE_CLIENT_ID", + "GOOGLE_ANDROID_CLIENT_ID": "YOUR_ANDROID_GOOGLE_CLIENT_ID", + "GOOGLE_WEB_CLIENT_ID": "YOUR_WEB_GOOGLE_CLIENT_ID", "router": {}, "eas": { "projectId": "69fef292-00c4-4045-93ec-3bd05d7e8dac" 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() { + ); } 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(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 ( + + + + router.back()}> + + + + +

{t("resetPassword")}

+ {t("resetPasswordHelp")} +
+ + + {mode === "request" ? ( + + ) : ( + <> + + + + + + + )} + + {error ? {error} : null} + {success ? {success} : null} + +