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:
parent
5d2db6d642
commit
aa9466aa92
@ -32,6 +32,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
"Rebreak greift auf Fotos zu, damit du sie in deinen Posts teilen kannst.",
|
"Rebreak greift auf Fotos zu, damit du sie in deinen Posts teilen kannst.",
|
||||||
NSPhotoLibraryAddUsageDescription:
|
NSPhotoLibraryAddUsageDescription:
|
||||||
"Rebreak speichert Bilder in deine Foto-Mediathek.",
|
"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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,9 @@ import { useAuthStore } from '../stores/auth';
|
|||||||
import { useThemeStore } from '../stores/theme';
|
import { useThemeStore } from '../stores/theme';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import { useLanguageStore } from '../stores/language';
|
import { useLanguageStore } from '../stores/language';
|
||||||
|
import { useAppLockStore } from '../stores/appLock';
|
||||||
import { BrandSplash } from '../components/BrandSplash';
|
import { BrandSplash } from '../components/BrandSplash';
|
||||||
|
import { AppLockGate } from '../components/AppLockGate';
|
||||||
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
|
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
|
||||||
import '../lib/i18n'; // i18next-Init via Side-Effect
|
import '../lib/i18n'; // i18next-Init via Side-Effect
|
||||||
import '../global.css';
|
import '../global.css';
|
||||||
@ -49,6 +51,8 @@ function RootLayoutInner() {
|
|||||||
const initTheme = useThemeStore((s) => s.init);
|
const initTheme = useThemeStore((s) => s.init);
|
||||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||||
const initLanguage = useLanguageStore((s) => s.init);
|
const initLanguage = useLanguageStore((s) => s.init);
|
||||||
|
const initAppLock = useAppLockStore((s) => s.init);
|
||||||
|
const appLockReady = useAppLockStore((s) => s.ready);
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const [fontsLoaded] = useFonts({
|
const [fontsLoaded] = useFonts({
|
||||||
Nunito_400Regular,
|
Nunito_400Regular,
|
||||||
@ -61,20 +65,21 @@ function RootLayoutInner() {
|
|||||||
init();
|
init();
|
||||||
initTheme();
|
initTheme();
|
||||||
initLanguage();
|
initLanguage();
|
||||||
|
initAppLock();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fontsLoaded && !loading) {
|
if (fontsLoaded && !loading && appLockReady) {
|
||||||
SplashScreen.hideAsync();
|
SplashScreen.hideAsync();
|
||||||
}
|
}
|
||||||
}, [fontsLoaded, loading]);
|
}, [fontsLoaded, loading, appLockReady]);
|
||||||
|
|
||||||
if (!fontsLoaded || loading) {
|
if (!fontsLoaded || loading || !appLockReady) {
|
||||||
return <BrandSplash />;
|
return <BrandSplash />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AppLockGate>
|
||||||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||||
<DeviceLimitReachedSheet />
|
<DeviceLimitReachedSheet />
|
||||||
<Stack
|
<Stack
|
||||||
@ -152,7 +157,7 @@ function RootLayoutInner() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</AppLockGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
Linking,
|
Linking,
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
@ -18,6 +19,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { LanguageIcon } from '../components/icons/LanguageIcon';
|
import { LanguageIcon } from '../components/icons/LanguageIcon';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { useAppLockStore } from '../stores/appLock';
|
||||||
import { useThemeStore, type ThemeMode } from '../stores/theme';
|
import { useThemeStore, type ThemeMode } from '../stores/theme';
|
||||||
import { useLanguageStore, type AppLanguage } from '../stores/language';
|
import { useLanguageStore, type AppLanguage } from '../stores/language';
|
||||||
import { useUserPlan } from '../hooks/useUserPlan';
|
import { useUserPlan } from '../hooks/useUserPlan';
|
||||||
@ -152,6 +154,12 @@ type SectionRow = {
|
|||||||
actions: MenuAction[];
|
actions: MenuAction[];
|
||||||
onSelect: (id: string) => void;
|
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 = {
|
type Section = {
|
||||||
@ -165,6 +173,10 @@ export default function SettingsScreen() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { signOut } = useAuthStore();
|
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 { mode: themeMode, setMode: setThemeMode } = useThemeStore();
|
||||||
const { language, setLanguage } = useLanguageStore();
|
const { language, setLanguage } = useLanguageStore();
|
||||||
const { plan } = useUserPlan();
|
const { plan } = useUserPlan();
|
||||||
@ -183,6 +195,18 @@ export default function SettingsScreen() {
|
|||||||
const subscriptionSheetRef = useRef<TrueSheet>(null);
|
const subscriptionSheetRef = useRef<TrueSheet>(null);
|
||||||
const planSheetRef = 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() {
|
async function handleSignOut() {
|
||||||
Alert.alert(t('auth.signOut'), '', [
|
Alert.alert(t('auth.signOut'), '', [
|
||||||
{ text: t('common.cancel'), style: 'cancel' },
|
{ text: t('common.cancel'), style: 'cancel' },
|
||||||
@ -228,6 +252,24 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
const sections: Section[] = [
|
const sections: Section[] = [
|
||||||
// Profile-Section entfernt — Profile-Edits sind in /profile-Page direkt
|
// 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',
|
key: 'theme',
|
||||||
title: t('settings.section_theme'),
|
title: t('settings.section_theme'),
|
||||||
@ -472,6 +514,21 @@ export default function SettingsScreen() {
|
|||||||
opacity: row.soon ? 0.5 : 1,
|
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
|
// Row mit Menu: Label-Bereich nicht tappable, MenuView nur am End-Anchor
|
||||||
if (row.menu) {
|
if (row.menu) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
35
apps/rebreak-native/components/AppLockGate.tsx
Normal file
35
apps/rebreak-native/components/AppLockGate.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
161
apps/rebreak-native/components/LockScreen.tsx
Normal file
161
apps/rebreak-native/components/LockScreen.tsx
Normal 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
|
||||||
|
* 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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -88,6 +88,14 @@
|
|||||||
"subtitle": "Zusammen schaffen wir das.",
|
"subtitle": "Zusammen schaffen wir das.",
|
||||||
"madeInGermany": "Made in Germany"
|
"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": {
|
"appHeader": {
|
||||||
"appName": "ReBreak",
|
"appName": "ReBreak",
|
||||||
"sosLabel": "SOS",
|
"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_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_title": "Gerät entfernen",
|
||||||
"devices_remove_desc": "Das Gerät wird freigegeben. Es kann sich beim nächsten Login erneut registrieren.",
|
"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": {
|
"device_limit": {
|
||||||
"title": "Geräte-Limit erreicht",
|
"title": "Geräte-Limit erreicht",
|
||||||
|
|||||||
@ -88,6 +88,14 @@
|
|||||||
"subtitle": "Together we'll make it.",
|
"subtitle": "Together we'll make it.",
|
||||||
"madeInGermany": "Made in Germany"
|
"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": {
|
"appHeader": {
|
||||||
"appName": "ReBreak",
|
"appName": "ReBreak",
|
||||||
"sosLabel": "SOS",
|
"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_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_title": "Remove device",
|
||||||
"devices_remove_desc": "The device slot will be freed. It can re-register on next sign-in.",
|
"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": {
|
"device_limit": {
|
||||||
"title": "Device limit reached",
|
"title": "Device limit reached",
|
||||||
|
|||||||
@ -37,6 +37,7 @@
|
|||||||
"expo-haptics": "^15.0.8",
|
"expo-haptics": "^15.0.8",
|
||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-linking": "~8.0.12",
|
"expo-linking": "~8.0.12",
|
||||||
|
"expo-local-authentication": "~17.0.8",
|
||||||
"expo-localization": "~17.0.8",
|
"expo-localization": "~17.0.8",
|
||||||
"expo-modules-core": "^3.0.30",
|
"expo-modules-core": "^3.0.30",
|
||||||
"expo-notifications": "~0.32.17",
|
"expo-notifications": "~0.32.17",
|
||||||
|
|||||||
92
apps/rebreak-native/stores/appLock.ts
Normal file
92
apps/rebreak-native/stores/appLock.ts
Normal 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
13
pnpm-lock.yaml
generated
@ -195,6 +195,9 @@ importers:
|
|||||||
expo-linking:
|
expo-linking:
|
||||||
specifier: ~8.0.12
|
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)
|
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:
|
expo-localization:
|
||||||
specifier: ~17.0.8
|
specifier: ~17.0.8
|
||||||
version: 17.0.8(expo@54.0.34)(react@19.1.0)
|
version: 17.0.8(expo@54.0.34)(react@19.1.0)
|
||||||
@ -5563,6 +5566,11 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
expo-local-authentication@17.0.8:
|
||||||
|
resolution: {integrity: sha512-Q5fXHhu6w3pVPlFCibU72SYIAN+9wX7QpFn9h49IUqs0Equ44QgswtGrxeh7fdnDqJrrYGPet5iBzjnE70uolA==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-localization@17.0.8:
|
expo-localization@17.0.8:
|
||||||
resolution: {integrity: sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==}
|
resolution: {integrity: sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -15545,6 +15553,11 @@ snapshots:
|
|||||||
- expo
|
- expo
|
||||||
- supports-color
|
- 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):
|
expo-localization@17.0.8(expo@54.0.34)(react@19.1.0):
|
||||||
dependencies:
|
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)
|
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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user