summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md18
-rw-r--r--api/client.ts36
-rw-r--r--api/controls.ts10
-rw-r--r--api/types.ts4
-rw-r--r--app.json3
-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
-rw-r--r--app/(tabs)/controls.tsx90
-rw-r--r--lib/auth.tsx25
-rw-r--r--lib/google-auth.ts116
-rw-r--r--lib/locales.ts63
-rw-r--r--package.json2
-rw-r--r--pnpm-lock.yaml48
15 files changed, 737 insertions, 29 deletions
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<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 = {
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() {
<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)