From 1e7d76aa7900b7f55954cfbcc350246840195ffb Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 26 Sep 2026 09:07:49 +0200 Subject: [PATCH] feat(mobile/ios): iOS simulator build profile and native Sign in with Apple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eas.json: new `preview-simulator` profile (extends preview, ios.simulator) for EAS cloud simulator builds without an Apple Developer account - app.json: iOS deployment target 17.0 via expo-build-properties (@clerk/expo's ClerkKit SPM package requires iOS 17 — pod install failed on the default 16.4), iPhone-only (supportsTablet: false), usesAppleSignIn, ITSAppUsesNonExemptEncryption: false - AppleSignInButton: native Apple sheet via Clerk's useSignInWithApple (App Store Guideline 4.8, since Google login is offered); same legal-consent handling as GoogleSignInButton; iOS-only - Not yet testable end-to-end: needs Apple Developer account, the Sign in with Apple capability on the App ID and Apple enabled in Clerk Co-Authored-By: Claude Opus 5.5 --- mobile/app.json | 19 +++- mobile/app/(auth)/sign-in.tsx | 5 + mobile/app/(auth)/sign-up.tsx | 2 + mobile/eas.json | 6 ++ mobile/package-lock.json | 36 ++++++- mobile/package.json | 3 + mobile/src/components/AppleSignInButton.tsx | 105 ++++++++++++++++++++ mobile/src/i18n/locales/de.json | 4 +- mobile/src/i18n/locales/en.json | 4 +- 9 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 mobile/src/components/AppleSignInButton.tsx diff --git a/mobile/app.json b/mobile/app.json index c8f7169..9856d4d 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -8,8 +8,12 @@ "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", "ios": { - "supportsTablet": true, - "bundleIdentifier": "org.pawfeed.app" + "supportsTablet": false, + "bundleIdentifier": "org.pawfeed.app", + "usesAppleSignIn": true, + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } }, "android": { "package": "org.pawfeed.app", @@ -64,7 +68,16 @@ } ], "expo-web-browser", - "@sentry/react-native" + "expo-apple-authentication", + "@sentry/react-native", + [ + "expo-build-properties", + { + "ios": { + "deploymentTarget": "17.0" + } + } + ] ], "extra": { "router": {}, diff --git a/mobile/app/(auth)/sign-in.tsx b/mobile/app/(auth)/sign-in.tsx index d5995be..61524e3 100644 --- a/mobile/app/(auth)/sign-in.tsx +++ b/mobile/app/(auth)/sign-in.tsx @@ -8,6 +8,7 @@ import { TextField } from "../../src/components/TextField"; import { PasswordField } from "../../src/components/PasswordField"; import { LanguageSwitcher } from "../../src/components/LanguageSwitcher"; import { GoogleSignInButton } from "../../src/components/GoogleSignInButton"; +import { AppleSignInButton } from "../../src/components/AppleSignInButton"; import { useTheme } from "../../src/context/ThemeContext"; import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens"; @@ -107,6 +108,10 @@ export default function SignInScreen() { onError={setError} onNeedsConsent={() => setError(t("auth.googleNeedsSignUpError"))} /> + setError(t("auth.appleNeedsSignUpError"))} + /> {t("auth.orDivider")} {t("auth.signUpTitle")} + {t("auth.orDivider")} void; + /** Same contract as GoogleSignInButton's `legalAccepted` — see there. */ + legalAccepted?: boolean; + /** Same contract as GoogleSignInButton's `onNeedsConsent` — see there. */ + onNeedsConsent?: () => void; + disabled?: boolean; +}; + +/** + * "Sign in with Apple" — required by App Store Review Guideline 4.8 because + * the app offers Google login. iOS only: renders nothing on Android or on an + * iOS device where Apple Authentication isn't available. + * + * Uses Clerk's native `useSignInWithApple()` (expo-apple-authentication + * sheet + `oauth_token_apple` token exchange) instead of the browser-based + * `oauth_apple` SSO flow — Apple expects the native sheet, and it avoids the + * redirect-URL pitfalls the Google SSO flow had. + * + * Uses Apple's own AppleAuthenticationButton (HIG-mandated look), not a + * custom Pressable like GoogleSignInButton. Legal-consent handling mirrors + * GoogleSignInButton: Clerk's sign-up comes back `missing_requirements` + * with `legal_accepted` for brand-new accounts. + */ +export function AppleSignInButton(props: Props) { + const [isAvailable, setIsAvailable] = useState(false); + + useEffect(() => { + if (Platform.OS !== "ios") return; + AppleAuthentication.isAvailableAsync().then(setIsAvailable).catch(() => setIsAvailable(false)); + }, []); + + if (!isAvailable) return null; + return ; +} + +function AppleSignInButtonInner({ onError, legalAccepted, onNeedsConsent, disabled }: Props) { + const { t } = useTranslation(); + const { scheme } = useTheme(); + const router = useRouter(); + const { startAppleAuthenticationFlow } = useSignInWithApple(); + const [isPending, setIsPending] = useState(false); + + const onPress = async () => { + setIsPending(true); + try { + const result = await startAppleAuthenticationFlow(); + const { setActive, signUp } = result; + let createdSessionId = result.createdSessionId; + + if (!createdSessionId && signUp?.status === "missing_requirements" && signUp.missingFields.includes("legal_accepted")) { + if (!legalAccepted) { + onNeedsConsent?.(); + return; + } + const updatedSignUp = await signUp.update({ legalAccepted: true }); + createdSessionId = updatedSignUp.createdSessionId; + } + + if (createdSessionId && setActive) { + await setActive({ session: createdSessionId }); + router.replace("/(app)"); + } + // No createdSessionId otherwise (user cancelled the Apple sheet). + } catch (err) { + Sentry.captureException(err); + onError(err instanceof Error ? err.message : t("auth.appleSignInError")); + } finally { + setIsPending(false); + } + }; + + const isInactive = isPending || !!disabled; + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + button: { height: 48, width: "100%" }, + disabled: { opacity: 0.5 }, +}); diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 62cbedc..ee24ead 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -240,7 +240,9 @@ "googleButton": "Mit Google anmelden", "orDivider": "oder", "googleSignInError": "Anmeldung mit Google fehlgeschlagen. Bitte versuche es erneut.", - "googleNeedsSignUpError": "Für dieses Google-Konto existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen." + "googleNeedsSignUpError": "Für dieses Google-Konto existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen.", + "appleSignInError": "Anmeldung mit Apple fehlgeschlagen. Bitte versuche es erneut.", + "appleNeedsSignUpError": "Für diese Apple-ID existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen." }, "onboarding": { "addPetTitle": "Tier hinzufügen", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 633e6c0..4765935 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -240,7 +240,9 @@ "googleButton": "Continue with Google", "orDivider": "or", "googleSignInError": "Google sign-in failed. Please try again.", - "googleNeedsSignUpError": "There's no PawFeed account for this Google account yet. Please continue via \"Sign up\" to accept the Terms of Service." + "googleNeedsSignUpError": "There's no PawFeed account for this Google account yet. Please continue via \"Sign up\" to accept the Terms of Service.", + "appleSignInError": "Apple sign-in failed. Please try again.", + "appleNeedsSignUpError": "There's no PawFeed account for this Apple ID yet. Please continue via \"Sign up\" to accept the Terms of Service." }, "onboarding": { "addPetTitle": "Add pet",