chahinebrini aa9466aa92 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>
2026-05-12 19:41:56 +02:00

93 lines
3.1 KiB
TypeScript

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;
},
}));