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)