Files
beenvoice-app/contexts/AppLockContext.tsx
T
soconnor 0b2d65a4e9 Add Authentik sign-in, fix tab scroll insets, and polish multi-account auth.
Mobile app detects SSO per server, supports OAuth sign-in, and preserves saved
sessions when adding accounts. Tab screens get proper chrome layout and tab-bar
clearance with scrollable page headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 02:27:31 -04:00

300 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as LocalAuthentication from "expo-local-authentication";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useAccounts } from "@/contexts/AccountsContext";
import {
clearStoredPin,
getAppLockEnabled,
getBiometricEnabled,
getStoredPin,
isValidPin,
setAppLockEnabled,
setBiometricEnabled,
setStoredPin,
} from "@/lib/app-lock";
type AppLockContextValue = {
enabled: boolean;
biometricEnabled: boolean;
hasPin: boolean;
isLocked: boolean;
biometricAvailable: boolean;
biometricLabel: string;
unlockWithPin: (pin: string) => Promise<boolean>;
unlockWithBiometric: () => Promise<boolean>;
enableLock: (pin: string) => Promise<void>;
disableLock: (pin: string) => Promise<boolean>;
changePin: (currentPin: string, nextPin: string) => Promise<boolean>;
setUseBiometric: (enabled: boolean) => Promise<void>;
lock: () => void;
};
const AppLockContext = createContext<AppLockContextValue | null>(null);
export function AppLockProvider({ children }: { children: ReactNode }) {
const { activeAccountId } = useAccounts();
const [enabled, setEnabled] = useState(false);
const [biometricEnabled, setBiometricEnabledState] = useState(false);
const [hasPin, setHasPin] = useState(false);
const [isLocked, setIsLocked] = useState(false);
const [biometricAvailable, setBiometricAvailable] = useState(false);
const [biometricLabel, setBiometricLabel] = useState("Biometrics");
const wasBackgrounded = useRef(false);
const biometricUnlockInProgress = useRef(false);
const hydrated = useRef(false);
useEffect(() => {
if (!activeAccountId) {
setEnabled(false);
setHasPin(false);
setBiometricEnabledState(false);
setIsLocked(false);
hydrated.current = false;
return;
}
let cancelled = false;
hydrated.current = false;
const accountId = activeAccountId;
async function hydrate() {
const [lockEnabled, pin, bioEnabled, hasHardware, isEnrolled, authTypes] =
await Promise.all([
getAppLockEnabled(accountId),
getStoredPin(accountId),
getBiometricEnabled(accountId),
LocalAuthentication.hasHardwareAsync(),
LocalAuthentication.isEnrolledAsync(),
LocalAuthentication.supportedAuthenticationTypesAsync(),
]);
if (cancelled) return;
const bioAvailable = hasHardware && isEnrolled;
setEnabled(lockEnabled);
setHasPin(Boolean(pin));
setBiometricEnabledState(bioEnabled && bioAvailable);
setBiometricAvailable(bioAvailable);
setBiometricLabel(
authTypes.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)
? "Face ID"
: authTypes.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)
? "Touch ID"
: "Biometrics",
);
setIsLocked(lockEnabled);
hydrated.current = true;
}
void hydrate();
return () => {
cancelled = true;
};
}, [activeAccountId]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (!hydrated.current || !enabled || !activeAccountId) return;
// Only true backgrounding should re-lock — `inactive` fires during Face ID,
// Control Center, and other system sheets and must not trigger another lock.
if (nextState === "background") {
wasBackgrounded.current = true;
}
if (
nextState === "active" &&
wasBackgrounded.current &&
!biometricUnlockInProgress.current
) {
wasBackgrounded.current = false;
setIsLocked(true);
}
});
return () => subscription.remove();
}, [enabled, activeAccountId]);
const unlockWithPin = useCallback(
async (pin: string) => {
if (!activeAccountId) return false;
const stored = await getStoredPin(activeAccountId);
if (!stored || stored !== pin) {
return false;
}
wasBackgrounded.current = false;
setIsLocked(false);
return true;
},
[activeAccountId],
);
const unlockWithBiometric = useCallback(async () => {
if (!biometricAvailable || !activeAccountId) {
return false;
}
biometricUnlockInProgress.current = true;
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Unlock beenvoice",
cancelLabel: "Use PIN",
disableDeviceFallback: false,
biometricsSecurityLevel: "weak",
});
if (!result.success) {
return false;
}
if (!biometricEnabled) {
await setBiometricEnabled(activeAccountId, true);
setBiometricEnabledState(true);
}
wasBackgrounded.current = false;
setIsLocked(false);
return true;
} finally {
biometricUnlockInProgress.current = false;
}
}, [biometricAvailable, biometricEnabled, activeAccountId]);
const enableLock = useCallback(
async (pin: string) => {
if (!activeAccountId) {
throw new Error("No active account");
}
if (!isValidPin(pin)) {
throw new Error("PIN must be 46 digits");
}
const [hasHardware, isEnrolled] = await Promise.all([
LocalAuthentication.hasHardwareAsync(),
LocalAuthentication.isEnrolledAsync(),
]);
const bioAvailable = hasHardware && isEnrolled;
await setStoredPin(activeAccountId, pin);
await setAppLockEnabled(activeAccountId, true);
setHasPin(true);
setEnabled(true);
setIsLocked(false);
if (bioAvailable) {
await setBiometricEnabled(activeAccountId, true);
setBiometricEnabledState(true);
}
},
[activeAccountId],
);
const disableLock = useCallback(
async (pin: string) => {
if (!activeAccountId) return false;
const stored = await getStoredPin(activeAccountId);
if (!stored || stored !== pin) {
return false;
}
await setAppLockEnabled(activeAccountId, false);
await clearStoredPin(activeAccountId);
await setBiometricEnabled(activeAccountId, false);
setEnabled(false);
setHasPin(false);
setBiometricEnabledState(false);
setIsLocked(false);
return true;
},
[activeAccountId],
);
const changePin = useCallback(
async (currentPin: string, nextPin: string) => {
if (!activeAccountId) return false;
const stored = await getStoredPin(activeAccountId);
if (!stored || stored !== currentPin || !isValidPin(nextPin)) {
return false;
}
await setStoredPin(activeAccountId, nextPin);
return true;
},
[activeAccountId],
);
const setUseBiometric = useCallback(
async (next: boolean) => {
if (!activeAccountId) return;
if (next) {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: `Enable ${biometricLabel}`,
cancelLabel: "Cancel",
disableDeviceFallback: true,
});
if (!result.success) return;
}
await setBiometricEnabled(activeAccountId, next);
setBiometricEnabledState(next);
},
[biometricLabel, activeAccountId],
);
const lock = useCallback(() => {
if (enabled) {
setIsLocked(true);
}
}, [enabled]);
const value = useMemo(
() => ({
enabled,
biometricEnabled,
hasPin,
isLocked,
biometricAvailable,
biometricLabel,
unlockWithPin,
unlockWithBiometric,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
}),
[
enabled,
biometricEnabled,
hasPin,
isLocked,
biometricAvailable,
biometricLabel,
unlockWithPin,
unlockWithBiometric,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
],
);
return <AppLockContext.Provider value={value}>{children}</AppLockContext.Provider>;
}
export function useAppLock() {
const context = useContext(AppLockContext);
if (!context) {
throw new Error("useAppLock must be used within AppLockProvider");
}
return context;
}