diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts index e05edbe..e86c59f 100644 --- a/apps/rebreak-native/app.config.ts +++ b/apps/rebreak-native/app.config.ts @@ -32,6 +32,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ "Rebreak greift auf Fotos zu, damit du sie in deinen Posts teilen kannst.", NSPhotoLibraryAddUsageDescription: "Rebreak speichert Bilder in deine Foto-Mediathek.", + NSFaceIDUsageDescription: + "Rebreak nutzt Face ID, um die App zu entsperren — damit niemand außer dir sie öffnen kann.", }, }, diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index ba758ef..9758d30 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -19,7 +19,9 @@ import { useAuthStore } from '../stores/auth'; import { useThemeStore } from '../stores/theme'; import { useColors } from '../lib/theme'; import { useLanguageStore } from '../stores/language'; +import { useAppLockStore } from '../stores/appLock'; import { BrandSplash } from '../components/BrandSplash'; +import { AppLockGate } from '../components/AppLockGate'; import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet'; import '../lib/i18n'; // i18next-Init via Side-Effect import '../global.css'; @@ -49,6 +51,8 @@ function RootLayoutInner() { const initTheme = useThemeStore((s) => s.init); const colorScheme = useThemeStore((s) => s.colorScheme); const initLanguage = useLanguageStore((s) => s.init); + const initAppLock = useAppLockStore((s) => s.init); + const appLockReady = useAppLockStore((s) => s.ready); const colors = useColors(); const [fontsLoaded] = useFonts({ Nunito_400Regular, @@ -61,20 +65,21 @@ function RootLayoutInner() { init(); initTheme(); initLanguage(); + initAppLock(); }, []); useEffect(() => { - if (fontsLoaded && !loading) { + if (fontsLoaded && !loading && appLockReady) { SplashScreen.hideAsync(); } - }, [fontsLoaded, loading]); + }, [fontsLoaded, loading, appLockReady]); - if (!fontsLoaded || loading) { + if (!fontsLoaded || loading || !appLockReady) { return ; } return ( - <> + - + ); } diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx index 3e08d42..9b6995d 100644 --- a/apps/rebreak-native/app/settings.tsx +++ b/apps/rebreak-native/app/settings.tsx @@ -4,6 +4,7 @@ import { Linking, Platform, ScrollView, + Switch, Text, TouchableOpacity, View, @@ -18,6 +19,7 @@ import { useTranslation } from 'react-i18next'; import { LanguageIcon } from '../components/icons/LanguageIcon'; import { useColors } from '../lib/theme'; import { useAuthStore } from '../stores/auth'; +import { useAppLockStore } from '../stores/appLock'; import { useThemeStore, type ThemeMode } from '../stores/theme'; import { useLanguageStore, type AppLanguage } from '../stores/language'; import { useUserPlan } from '../hooks/useUserPlan'; @@ -152,6 +154,12 @@ type SectionRow = { actions: MenuAction[]; onSelect: (id: string) => void; }; + /** Wenn gesetzt, rendert ein native UISwitch am End-Anchor statt Chevron/Value */ + toggle?: { + value: boolean; + onValueChange: (next: boolean) => void; + disabled?: boolean; + }; }; type Section = { @@ -165,6 +173,10 @@ export default function SettingsScreen() { const insets = useSafeAreaInsets(); const { t } = useTranslation(); const { signOut } = useAuthStore(); + const appLockEnabled = useAppLockStore((s) => s.enabled); + const appLockAvailable = useAppLockStore((s) => s.available); + const setAppLockEnabled = useAppLockStore((s) => s.setEnabled); + const appLockAuthenticate = useAppLockStore((s) => s.authenticate); const { mode: themeMode, setMode: setThemeMode } = useThemeStore(); const { language, setLanguage } = useLanguageStore(); const { plan } = useUserPlan(); @@ -183,6 +195,18 @@ export default function SettingsScreen() { const subscriptionSheetRef = useRef(null); const planSheetRef = useRef(null); + async function handleToggleAppLock(next: boolean) { + if (next) { + // Erst verifizieren, dass Face ID / Touch ID / Passcode klappt — sonst nicht aktivieren. + // (Switch ist controlled über appLockEnabled → springt von selbst zurück wenn wir nicht persistieren.) + const ok = await appLockAuthenticate(t('applock.prompt')); + if (!ok) return; + await setAppLockEnabled(true); + } else { + await setAppLockEnabled(false); + } + } + async function handleSignOut() { Alert.alert(t('auth.signOut'), '', [ { text: t('common.cancel'), style: 'cancel' }, @@ -228,6 +252,24 @@ export default function SettingsScreen() { const sections: Section[] = [ // Profile-Section entfernt — Profile-Edits sind in /profile-Page direkt + { + key: 'security', + title: t('settings.section_security'), + rows: [ + { + icon: 'lock-closed-outline', + label: t('settings.app_lock'), + sublabel: appLockAvailable + ? t('settings.app_lock_desc') + : t('settings.app_lock_unavailable'), + toggle: { + value: appLockEnabled, + onValueChange: handleToggleAppLock, + disabled: !appLockAvailable, + }, + }, + ], + }, { key: 'theme', title: t('settings.section_theme'), @@ -472,6 +514,21 @@ export default function SettingsScreen() { opacity: row.soon ? 0.5 : 1, }; + // Row mit Toggle: native UISwitch am End-Anchor, Label-Bereich nicht tappable + if (row.toggle) { + return ( + + {rowLeft} + + + ); + } + // Row mit Menu: Label-Bereich nicht tappable, MenuView nur am End-Anchor if (row.menu) { return ( diff --git a/apps/rebreak-native/components/AppLockGate.tsx b/apps/rebreak-native/components/AppLockGate.tsx new file mode 100644 index 0000000..4130505 --- /dev/null +++ b/apps/rebreak-native/components/AppLockGate.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { AppState } from 'react-native'; +import { useAppLockStore } from '../stores/appLock'; +import { useAuthStore } from '../stores/auth'; +import { LockScreen } from './LockScreen'; + +/** + * Hängt die App-Sperre vor den App-Inhalt: + * - sperrt sofort wenn die App in den Hintergrund geht (`background`-State — + * NICHT `inactive`, sonst würde der App-Switcher-Peek schon sperren) + * - rendert den LockScreen solange `enabled && locked && session` gilt + * + * `init()` der appLock-Store wird im RootLayout zusammen mit den anderen Stores + * aufgerufen; der Splash wartet auf `ready`, daher gibt es hier kein Flash-of- + * unlocked-content beim Kaltstart (init setzt `locked = enabled`). + */ +export function AppLockGate({ children }: { children: React.ReactNode }) { + const enabled = useAppLockStore((s) => s.enabled); + const locked = useAppLockStore((s) => s.locked); + const lock = useAppLockStore((s) => s.lock); + const session = useAuthStore((s) => s.session); + + useEffect(() => { + if (!enabled) return; + const sub = AppState.addEventListener('change', (state) => { + if (state === 'background') lock(); + }); + return () => sub.remove(); + }, [enabled, lock]); + + if (enabled && locked && session) { + return ; + } + return <>{children}; +} diff --git a/apps/rebreak-native/components/LockScreen.tsx b/apps/rebreak-native/components/LockScreen.tsx new file mode 100644 index 0000000..9f44ddb --- /dev/null +++ b/apps/rebreak-native/components/LockScreen.tsx @@ -0,0 +1,161 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Alert, Animated, AppState, Image, Text, TouchableOpacity, View } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { useRouter } from 'expo-router'; +import { useAppLockStore } from '../stores/appLock'; +import { useAuthStore } from '../stores/auth'; + +/** + * Vollbild-Overlay, das den App-Inhalt verdeckt solange die App-Sperre aktiv und + * `locked` ist (siehe AppLockGate). Beim Mount — und jedes Mal wenn man aus dem + * Hintergrund zur noch-gesperrten App zurückkommt — wird automatisch der + * Face-ID/Touch-ID/Passcode-Prompt ausgelöst; schlägt er fehl oder bricht der + * User ab, bleibt der „Entsperren"-Button stehen (kein Auto-Retry-Loop — die + * inactive→active-Transition direkt nach einem abgebrochenen Prompt löst NICHT + * neu aus, nur background→active). + * + * „Abmelden" unten ist die Notausfahrt: clear't die Session → beim nächsten Start + * gibt es keine Session → keine Sperre → frischer Login. Verhindert ein echtes + * Aussperren falls Biometrie + Passcode versagen. + */ +export function LockScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const authenticate = useAppLockStore((s) => s.authenticate); + const signOut = useAuthStore((s) => s.signOut); + const [busy, setBusy] = useState(false); + const inFlight = useRef(false); + + // dezenter Atem-Puls auf dem Icon (matcht den Splash-Vibe, ohne dessen ganze Choreo) + const pulse = useRef(new Animated.Value(1)).current; + useEffect(() => { + Animated.loop( + Animated.sequence([ + Animated.timing(pulse, { toValue: 1.04, duration: 1300, useNativeDriver: true }), + Animated.timing(pulse, { toValue: 1, duration: 1300, useNativeDriver: true }), + ]), + ).start(); + }, [pulse]); + + const tryUnlock = useCallback(async () => { + if (inFlight.current) return; + inFlight.current = true; + setBusy(true); + try { + await authenticate(t('applock.prompt')); + } finally { + inFlight.current = false; + setBusy(false); + } + }, [authenticate, t]); + + // Auto-Prompt beim ersten Erscheinen + useEffect(() => { + tryUnlock(); + }, [tryUnlock]); + + // Rückkehr aus dem Hintergrund zur noch gesperrten App → erneut prompten + useEffect(() => { + let prev = AppState.currentState; + const sub = AppState.addEventListener('change', (next) => { + if (prev === 'background' && next === 'active') tryUnlock(); + prev = next; + }); + return () => sub.remove(); + }, [tryUnlock]); + + function handleSignOut() { + Alert.alert(t('applock.signOut_title'), t('applock.signOut_body'), [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('auth.signOut'), + style: 'destructive', + onPress: async () => { + await signOut(); + router.replace('/'); + }, + }, + ]); + } + + return ( + + + + + + + + + {t('applock.title')} + + + {t('applock.subtitle')} + + + + + + + {t('applock.unlock')} + + + + + + {t('auth.signOut')} + + + + ); +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 979f55b..8186bce 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -88,6 +88,14 @@ "subtitle": "Zusammen schaffen wir das.", "madeInGermany": "Made in Germany" }, + "applock": { + "title": "Rebreak ist gesperrt", + "subtitle": "Entsperre die App, um fortzufahren.", + "unlock": "Entsperren", + "prompt": "Rebreak entsperren", + "signOut_title": "Abmelden?", + "signOut_body": "Danach kannst du dich wieder mit E-Mail und Passwort anmelden." + }, "appHeader": { "appName": "ReBreak", "sosLabel": "SOS", @@ -494,7 +502,11 @@ "devices_hint": "Geräte, die du entfernst, werden beim nächsten Login wieder registriert. Dieses Gerät kann nicht entfernt werden, solange du eingeloggt bist.", "devices_remove_title": "Gerät entfernen", "devices_remove_desc": "Das Gerät wird freigegeben. Es kann sich beim nächsten Login erneut registrieren.", - "devices_remove_confirm": "Entfernen" + "devices_remove_confirm": "Entfernen", + "section_security": "Sicherheit", + "app_lock": "App-Sperre", + "app_lock_desc": "Beim Öffnen mit Face ID, Touch ID oder Code entsperren", + "app_lock_unavailable": "Auf diesem Gerät nicht verfügbar" }, "device_limit": { "title": "Geräte-Limit erreicht", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 75f9bc5..e8a8f53 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -88,6 +88,14 @@ "subtitle": "Together we'll make it.", "madeInGermany": "Made in Germany" }, + "applock": { + "title": "Rebreak is locked", + "subtitle": "Unlock the app to continue.", + "unlock": "Unlock", + "prompt": "Unlock Rebreak", + "signOut_title": "Sign out?", + "signOut_body": "You can sign back in with your email and password afterwards." + }, "appHeader": { "appName": "ReBreak", "sosLabel": "SOS", @@ -494,7 +502,11 @@ "devices_hint": "Devices you remove will re-register on next sign-in. This device cannot be removed while you are signed in.", "devices_remove_title": "Remove device", "devices_remove_desc": "The device slot will be freed. It can re-register on next sign-in.", - "devices_remove_confirm": "Remove" + "devices_remove_confirm": "Remove", + "section_security": "Security", + "app_lock": "App lock", + "app_lock_desc": "Unlock with Face ID, Touch ID or passcode when opening", + "app_lock_unavailable": "Not available on this device" }, "device_limit": { "title": "Device limit reached", diff --git a/apps/rebreak-native/package.json b/apps/rebreak-native/package.json index 6f2fd0f..b7ab00e 100644 --- a/apps/rebreak-native/package.json +++ b/apps/rebreak-native/package.json @@ -37,6 +37,7 @@ "expo-haptics": "^15.0.8", "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.12", + "expo-local-authentication": "~17.0.8", "expo-localization": "~17.0.8", "expo-modules-core": "^3.0.30", "expo-notifications": "~0.32.17", diff --git a/apps/rebreak-native/stores/appLock.ts b/apps/rebreak-native/stores/appLock.ts new file mode 100644 index 0000000..eadcd4b --- /dev/null +++ b/apps/rebreak-native/stores/appLock.ts @@ -0,0 +1,92 @@ +import { create } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as LocalAuthentication from 'expo-local-authentication'; + +/** + * App-Sperre (Face ID / Touch ID). + * + * Liegt OBEN auf der bereits authentifizierten Supabase-Session — re-auth beim + * Öffnen, kein Login-Ersatz. Sucht-/Stigma-Schutz: wer dein entsperrtes iPhone + * nimmt, kann Rebreak nicht öffnen. + * + * - `enabled` → User-Präferenz, persistiert (AsyncStorage) + * - `locked` → in-memory: ist die App gerade hinter der Sperre? + * - `available`→ Gerät kann biometrisch ODER per Geräte-Passcode auth'en + * + * Lock-Timing: sofort beim Backgrounden (siehe AppLockGate / AppState-Listener). + */ + +const STORAGE_KEY = '@rebreak/app-lock-enabled'; + +/** Reason-String im System-Prompt von Face ID / Touch ID. Wird i18n überschrieben + * wo wir `t()` haben — dieser Default greift nur falls der Aufrufer keinen liefert. */ +const DEFAULT_PROMPT = 'Rebreak entsperren'; + +type AppLockState = { + /** persistierte User-Präferenz */ + enabled: boolean; + /** in-memory: App ist gerade gesperrt */ + locked: boolean; + /** Gerät unterstützt Biometrie/Passcode-Auth (sonst Toggle ausgrauen) */ + available: boolean; + /** init() durch */ + ready: boolean; + + init: () => Promise; + setEnabled: (enabled: boolean) => Promise; + /** sperren (App geht in den Hintergrund) */ + lock: () => void; + /** Face-ID/Touch-ID/Passcode-Prompt — bei Erfolg entsperrt. Gibt success zurück. */ + authenticate: (promptMessage?: string) => Promise; +}; + +export const useAppLockStore = create((set, get) => ({ + enabled: false, + locked: false, + available: false, + ready: false, + + init: async () => { + const [storedRaw, hasHardware, isEnrolled] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEY), + LocalAuthentication.hasHardwareAsync(), + LocalAuthentication.isEnrolledAsync(), + ]); + // available = Gerät hat Biometrie-Hardware UND mind. eine Methode eingerichtet + // (Face/Touch ODER Geräte-Passcode — isEnrolledAsync deckt beides ab). + const available = hasHardware && isEnrolled; + const enabled = storedRaw === 'true' && available; + set({ + enabled, + available, + // Cold-Start: wenn aktiviert → sofort gesperrt starten (kein Flash von App-Inhalt, + // der AppLockGate rendert dann den LockScreen bevor irgendwas sichtbar wird). + locked: enabled, + ready: true, + }); + }, + + setEnabled: async (enabled) => { + await AsyncStorage.setItem(STORAGE_KEY, enabled ? 'true' : 'false'); + set({ enabled, locked: false }); + }, + + lock: () => { + if (get().enabled) set({ locked: true }); + }, + + authenticate: async (promptMessage) => { + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: promptMessage ?? DEFAULT_PROMPT, + // Geräte-Passcode als Fallback erlauben (Face ID schlägt 3x fehl → Passcode). + // Wichtig, damit man sich nicht aus der App aussperrt. + disableDeviceFallback: false, + cancelLabel: undefined, + }); + if (result.success) { + set({ locked: false }); + return true; + } + return false; + }, +})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ce100b..2600f69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: expo-linking: specifier: ~8.0.12 version: 8.0.12(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + expo-local-authentication: + specifier: ~17.0.8 + version: 17.0.8(expo@54.0.34) expo-localization: specifier: ~17.0.8 version: 17.0.8(expo@54.0.34)(react@19.1.0) @@ -5563,6 +5566,11 @@ packages: react: '*' react-native: '*' + expo-local-authentication@17.0.8: + resolution: {integrity: sha512-Q5fXHhu6w3pVPlFCibU72SYIAN+9wX7QpFn9h49IUqs0Equ44QgswtGrxeh7fdnDqJrrYGPet5iBzjnE70uolA==} + peerDependencies: + expo: '*' + expo-localization@17.0.8: resolution: {integrity: sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==} peerDependencies: @@ -15545,6 +15553,11 @@ snapshots: - expo - supports-color + expo-local-authentication@17.0.8(expo@54.0.34): + dependencies: + expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) + invariant: 2.2.4 + expo-localization@17.0.8(expo@54.0.34)(react@19.1.0): dependencies: expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)