feat(rebreak-native): Face ID app lock (opt-in)

Privacy/stigma layer on top of the authenticated Supabase session — re-auth on
open so nobody but the user can open Rebreak. Not a login replacement.

- expo-local-authentication; NSFaceIDUsageDescription in app.config
- stores/appLock.ts: persisted `enabled` pref, in-memory `locked`, device-
  capability check (`available`), device-passcode fallback on biometric failure
- AppLockGate wraps the root layout: locks immediately on `background` (not
  `inactive` → app-switcher peek doesn't lock), renders LockScreen while
  `enabled && locked && session`
- LockScreen: dark brand screen, auto-prompts on mount + on return from
  background, "Abmelden" escape hatch (clears session → fresh login next launch)
- Settings: new "Sicherheit" section, native UISwitch; enabling requires a
  successful biometric prompt first; row disabled + explained when device has no
  biometrics/passcode
- de/en strings

Per product call: the lock gates the whole app incl. SOS (SOS already requires
an authenticated user, so there's no unauthenticated path to carve out).

Cold-start: appLock init blocks the splash → `locked` is set before first paint,
no flash of unlocked content. ios/ is gitignored so EAS prebuilds the new module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-12 19:41:56 +02:00
parent 5d2db6d642
commit aa9466aa92
10 changed files with 397 additions and 7 deletions

View File

@ -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.",
},
},

View File

@ -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 <BrandSplash />;
}
return (
<>
<AppLockGate>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<DeviceLimitReachedSheet />
<Stack
@ -152,7 +157,7 @@ function RootLayoutInner() {
}}
/>
</Stack>
</>
</AppLockGate>
);
}

View File

@ -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<TrueSheet>(null);
const planSheetRef = useRef<TrueSheet>(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 (
<View key={row.label} style={containerStyle}>
{rowLeft}
<Switch
value={row.toggle.value}
onValueChange={row.toggle.onValueChange}
disabled={row.toggle.disabled}
trackColor={{ false: colors.border, true: '#6366f1' }}
/>
</View>
);
}
// Row mit Menu: Label-Bereich nicht tappable, MenuView nur am End-Anchor
if (row.menu) {
return (

View File

@ -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 <LockScreen />;
}
return <>{children}</>;
}

View File

@ -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
* inactiveactive-Transition direkt nach einem abgebrochenen Prompt löst NICHT
* neu aus, nur backgroundactive).
*
* 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 (
<View
style={{
flex: 1,
backgroundColor: '#0f172a',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
gap: 24,
}}
>
<StatusBar style="light" />
<Animated.View style={{ transform: [{ scale: pulse }] }}>
<Image
source={require('../assets/icon.png')}
style={{ width: 96, height: 96, borderRadius: 22 }}
resizeMode="contain"
/>
</Animated.View>
<View style={{ alignItems: 'center', gap: 8 }}>
<Text
style={{
fontFamily: 'Nunito_800ExtraBold',
fontSize: 24,
color: '#ffffff',
textAlign: 'center',
}}
>
{t('applock.title')}
</Text>
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 14,
color: 'rgba(255,255,255,0.55)',
textAlign: 'center',
lineHeight: 20,
}}
>
{t('applock.subtitle')}
</Text>
</View>
<TouchableOpacity
onPress={tryUnlock}
disabled={busy}
activeOpacity={0.8}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: '#6366f1',
paddingVertical: 14,
paddingHorizontal: 28,
borderRadius: 14,
opacity: busy ? 0.6 : 1,
}}
>
<Ionicons name="lock-open-outline" size={18} color="#ffffff" />
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
{t('applock.unlock')}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleSignOut} activeOpacity={0.6} style={{ marginTop: 8 }}>
<Text
style={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 13,
color: 'rgba(255,255,255,0.35)',
}}
>
{t('auth.signOut')}
</Text>
</TouchableOpacity>
</View>
);
}

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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<void>;
setEnabled: (enabled: boolean) => Promise<void>;
/** 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<boolean>;
};
export const useAppLockStore = create<AppLockState>((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;
},
}));

13
pnpm-lock.yaml generated
View File

@ -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)