diff options
| author | JustZvan <justzvan@justzvan.xyz> | 2026-04-06 15:32:51 +0200 |
|---|---|---|
| committer | JustZvan <justzvan@justzvan.xyz> | 2026-04-06 15:32:51 +0200 |
| commit | 3273e7a0fbbce82f4ce6cacbcdb7b6d6848f6c1b (patch) | |
| tree | 7662ab528c950e5b3605d3f134dc89f399417c8d | |
| parent | 7eb8ccae48b0cc18a9dcaa9c3626a02df8e6d919 (diff) | |
feat: gallery scanning preferences
| -rw-r--r-- | README.md | 18 | ||||
| -rw-r--r-- | api/client.ts | 36 | ||||
| -rw-r--r-- | api/controls.ts | 10 | ||||
| -rw-r--r-- | api/types.ts | 4 | ||||
| -rw-r--r-- | app.json | 3 | ||||
| -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 | ||||
| -rw-r--r-- | lib/auth.tsx | 25 | ||||
| -rw-r--r-- | lib/google-auth.ts | 116 | ||||
| -rw-r--r-- | lib/locales.ts | 63 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 48 |
15 files changed, 737 insertions, 29 deletions
@@ -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<ControlsData> { const deviceId = apiClient.getSelectedDeviceId(); @@ -30,7 +30,7 @@ export async function getControlsData(): Promise<ControlsData> { export async function updateSafetyControl( key: string, - value: boolean + value: SafetyControlValue, ): Promise<void> { 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 = { @@ -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() { <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> ); } diff --git a/lib/auth.tsx b/lib/auth.tsx index b05c6a7..ac394f4 100644 --- a/lib/auth.tsx +++ b/lib/auth.tsx @@ -1,9 +1,9 @@ import { - createContext, - ReactNode, - useContext, - useEffect, - useState, + createContext, + ReactNode, + useContext, + useEffect, + useState, } from "react"; import { apiClient } from "../api/client"; @@ -17,6 +17,9 @@ type AuthContextType = AuthState & { email: string, password: string, ) => Promise<{ success: boolean; reason?: string }>; + signInWithGoogle: ( + idToken: string, + ) => Promise<{ success: boolean; reason?: string }>; signUp: ( email: string, password: string, @@ -60,13 +63,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { success: result.success, reason: result.reason }; }; + const signInWithGoogle = async (idToken: string) => { + const result = await apiClient.signInWithGoogle(idToken); + if (result.success) { + setState({ isLoading: false, isAuthenticated: true }); + } + return { success: result.success, reason: result.reason }; + }; + const signOut = async () => { await apiClient.signOut(); setState({ isLoading: false, isAuthenticated: false }); }; return ( - <AuthContext.Provider value={{ ...state, signIn, signUp, signOut }}> + <AuthContext.Provider + value={{ ...state, signIn, signInWithGoogle, signUp, signOut }} + > {children} </AuthContext.Provider> ); diff --git a/lib/google-auth.ts b/lib/google-auth.ts new file mode 100644 index 0000000..769da55 --- /dev/null +++ b/lib/google-auth.ts @@ -0,0 +1,116 @@ +import { ResponseType } from "expo-auth-session"; +import * as Google from "expo-auth-session/providers/google"; +import Constants from "expo-constants"; +import * as WebBrowser from "expo-web-browser"; +import { useEffect, useMemo, useState } from "react"; +import { useAuth } from "./auth"; +import { t } from "./locales"; + +WebBrowser.maybeCompleteAuthSession(); + +type GoogleClientIds = { + iosClientId?: string; + androidClientId?: string; + webClientId?: string; +}; + +function getGoogleClientIds(): GoogleClientIds { + const env = (process as any)?.env || {}; + const extra = + (Constants.manifest as any)?.extra || + (Constants.expoConfig as any)?.extra || + {}; + + return { + iosClientId: + env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID || extra.GOOGLE_IOS_CLIENT_ID, + androidClientId: + env.EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID || + extra.GOOGLE_ANDROID_CLIENT_ID, + webClientId: + env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID || + extra.GOOGLE_WEB_CLIENT_ID || + env.EXPO_PUBLIC_GOOGLE_EXPO_CLIENT_ID || + extra.GOOGLE_EXPO_CLIENT_ID, + }; +} + +export function useGoogleAuth() { + const { signInWithGoogle } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + const clientIds = useMemo(() => getGoogleClientIds(), []); + + const [request, response, promptAsync] = Google.useAuthRequest({ + iosClientId: clientIds.iosClientId, + androidClientId: clientIds.androidClientId, + webClientId: clientIds.webClientId, + responseType: ResponseType.IdToken, + scopes: ["openid", "profile", "email"], + selectAccount: true, + }); + + useEffect(() => { + const run = async () => { + if (!response) return; + + if (response.type === "success") { + const idToken = + response.params?.id_token || response.authentication?.idToken; + + if (!idToken) { + setError(t("googleMissingIdToken")); + setIsLoading(false); + return; + } + + const result = await signInWithGoogle(idToken); + if (!result.success) { + setError(result.reason || t("googleSignInError")); + } + setIsLoading(false); + return; + } + + if (response.type === "error") { + setError(response.error?.message || t("googleSignInError")); + } + + // On cancel/dismissed/locked, just reset loading and keep the current screen state. + setIsLoading(false); + }; + + run().catch(() => { + setError(t("googleSignInError")); + setIsLoading(false); + }); + }, [response, signInWithGoogle]); + + const continueWithGoogle = async () => { + setError(""); + + if (!request) { + setError(t("googleConfigMissing")); + return; + } + + setIsLoading(true); + + try { + const result = await promptAsync(); + if (result.type === "cancel" || result.type === "dismiss") { + setIsLoading(false); + } + } catch { + setError(t("googleSignInError")); + setIsLoading(false); + } + }; + + return { + continueWithGoogle, + isLoading, + error, + }; +} diff --git a/lib/locales.ts b/lib/locales.ts index d78621f..9ba29ba 100644 --- a/lib/locales.ts +++ b/lib/locales.ts @@ -36,12 +36,36 @@ const en = { createAccount: "Create Account", alreadyHaveAccount: "Already have an account?", dontHaveAccount: "Don't have an account?", + forgotPassword: "Forgot password?", + resetPassword: "Reset Password", + resetPasswordHelp: "Request a reset link or enter a token to set a new password.", + sendResetLink: "Send reset link", + resetPasswordToken: "Reset token", + resetPasswordTokenPlaceholder: "Paste your reset token", + newPassword: "New Password", + newPasswordPlaceholder: "Enter your new password", + confirmNewPassword: "Confirm New Password", + switchToResetWithToken: "I have a reset token", + switchToRequestReset: "I need a reset link", + passwordResetEmailSent: + "If that email exists, a reset link has been sent.", + passwordResetSuccess: "Your password has been reset. You can sign in now.", + resetTokenRequired: "Reset token is required", + resetPasswordError: "Unable to reset password. Please try again.", + backToSignIn: "Back to Sign In", invalidEmail: "Please enter a valid email", passwordRequired: "Password is required", passwordTooShort: "Password must be at least 8 characters", passwordsDoNotMatch: "Passwords do not match", signInError: "Unable to sign in. Please check your credentials.", signUpError: "Unable to create account. Please try again.", + continueWithGoogle: "Continue with Google", + googleAuthUnifiedHint: + "Google will sign you in or create your Buddy account automatically.", + googleSignInError: "Google sign in failed. Please try again.", + googleMissingIdToken: + "Google sign in did not return an ID token. Please try again.", + googleConfigMissing: "Google sign in is not configured yet. Contact support.", loadingAlerts: "Loading alerts...", allClearTitle: "All clear!", noAlertsToReview: "No alerts to review right now.", @@ -74,6 +98,12 @@ const en = { notifications: "Notifications", dangerousMessages: "Dangerous messages", newContactAdded: "New contact added", + galleryScanning: "Gallery scanning", + scanGalleryForInappropriateImages: "Scan gallery for inappropriate images.", + galleryScanningTitle: "Gallery scanning", + galleryScanningNone: "Do nothing", + galleryScanningNotify: "Notify me", + galleryScanningDelete: "Delete detected images", devices: "Devices", device: "device", devicesPlural: "devices", @@ -150,12 +180,39 @@ const hr: typeof en = { createAccount: "Izradi račun", alreadyHaveAccount: "Već imate račun?", dontHaveAccount: "Nemate račun?", + forgotPassword: "Zaboravljena lozinka?", + resetPassword: "Resetiraj lozinku", + resetPasswordHelp: + "Zatražite poveznicu za resetiranje ili unesite token za novu lozinku.", + sendResetLink: "Pošalji poveznicu za resetiranje", + resetPasswordToken: "Token za resetiranje", + resetPasswordTokenPlaceholder: "Zalijepite token za resetiranje", + newPassword: "Nova lozinka", + newPasswordPlaceholder: "Unesite novu lozinku", + confirmNewPassword: "Potvrdite novu lozinku", + switchToResetWithToken: "Imam token za resetiranje", + switchToRequestReset: "Trebam poveznicu za resetiranje", + passwordResetEmailSent: + "Ako taj e-mail postoji, poveznica za resetiranje je poslana.", + passwordResetSuccess: + "Lozinka je uspješno resetirana. Sada se možete prijaviti.", + resetTokenRequired: "Token za resetiranje je obavezan", + resetPasswordError: "Resetiranje lozinke nije uspjelo. Pokušajte ponovno.", + backToSignIn: "Natrag na prijavu", invalidEmail: "Unesite valjanu e-mail adresu", passwordRequired: "Lozinka je obavezna", passwordTooShort: "Lozinka mora imati najmanje 8 znakova", passwordsDoNotMatch: "Lozinke se ne podudaraju", signInError: "Prijava nije uspjela. Provjerite podatke.", signUpError: "Registracija nije uspjela. Pokušajte ponovno.", + continueWithGoogle: "Nastavi s Google računom", + googleAuthUnifiedHint: + "Google će vas prijaviti ili automatski izraditi Buddy račun.", + googleSignInError: "Google prijava nije uspjela. Pokušajte ponovno.", + googleMissingIdToken: + "Google prijava nije vratila ID token. Pokušajte ponovno.", + googleConfigMissing: + "Google prijava još nije konfigurirana. Kontaktirajte podršku.", loadingAlerts: "Učitavanje upozorenja...", allClearTitle: "Sve je u redu!", noAlertsToReview: "Trenutno nema upozorenja za pregled.", @@ -188,6 +245,12 @@ const hr: typeof en = { notifications: "Obavijesti", dangerousMessages: "Opasne poruke", newContactAdded: "Dodan novi kontakt", + galleryScanning: "Skeniranje galerije", + scanGalleryForInappropriateImages: "Skeniraj galeriju za neprimjerene slike.", + galleryScanningTitle: "Skeniranje galerije", + galleryScanningNone: "Ne poduzimaj ništa", + galleryScanningNotify: "Obavijesti me", + galleryScanningDelete: "Izbriši otkrivene slike", devices: "Uređaji", device: "uređaj", devicesPlural: "uređaji", diff --git a/package.json b/package.json index 0d8369b..cb61bbe 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "expo": "~54.0.31", + "expo-auth-session": "^7.0.10", "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.8", "expo-dev-client": "~6.0.20", "expo-device": "^8.0.10", "expo-font": "~14.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 756ab79..f873f53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,15 @@ importers: expo: specifier: ~54.0.31 version: 54.0.31(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-auth-session: + specifier: ^7.0.10 + version: 7.0.10(expo@54.0.31)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-constants: specifier: ~18.0.13 version: 18.0.13(expo@54.0.31)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) + expo-crypto: + specifier: ~15.0.8 + version: 15.0.8(expo@54.0.31) expo-dev-client: specifier: ~6.0.20 version: 6.0.20(expo@54.0.31) @@ -1392,41 +1398,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2167,12 +2181,23 @@ packages: react: '*' react-native: '*' + expo-auth-session@7.0.10: + resolution: {integrity: sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==} + peerDependencies: + react: '*' + react-native: '*' + expo-constants@18.0.13: resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==} peerDependencies: expo: '*' react-native: '*' + expo-crypto@15.0.8: + resolution: {integrity: sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==} + peerDependencies: + expo: '*' + expo-dev-client@6.0.20: resolution: {integrity: sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==} peerDependencies: @@ -2932,24 +2957,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -6884,6 +6913,20 @@ snapshots: transitivePeerDependencies: - supports-color + expo-auth-session@7.0.10(expo@54.0.31)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo-application: 7.0.8(expo@54.0.31) + expo-constants: 18.0.13(expo@54.0.31)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) + expo-crypto: 15.0.8(expo@54.0.31) + expo-linking: 8.0.11(expo@54.0.31)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-web-browser: 15.0.10(expo@54.0.31)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) + transitivePeerDependencies: + - expo + - supports-color + expo-constants@18.0.13(expo@54.0.31)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)): dependencies: '@expo/config': 12.0.13 @@ -6893,6 +6936,11 @@ snapshots: transitivePeerDependencies: - supports-color + expo-crypto@15.0.8(expo@54.0.31): + dependencies: + base64-js: 1.5.1 + expo: 54.0.31(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-dev-client@6.0.20(expo@54.0.31): dependencies: expo: 54.0.31(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) |