App lock & biometrics
A banking-style lock that gates the authenticated app behind a biometric unlock (Face ID / Touch ID / fingerprint). Native-only; on web this whole layer is a passthrough.
Why biometrics, not a WebAuthn passkey, on native
Real WebAuthn passkeys cannot run inside the Capacitor WebView: the origin is
https://localhost, not velon.finance (no associated-domains entitlement), so
navigator.credentials.get() fails. The native equivalent is a device-owner
biometric check via the local Biometric plugin (iOS LAContext, Android
BiometricPrompt), which unlocks a session that is already authenticated.
On web, the real WebAuthn passkey login flow is unchanged and remains the canonical “passkey”.
Lock triggers
| Trigger | Behavior |
|---|---|
| Cold start | If a session exists and biometric is available → app opens locked. |
| Background → foreground | Locks after a short grace (~1.5s) so OS sheets (the Face ID prompt itself, share sheet) don’t self-lock. |
| Idle | Locks after ~2 min of no interaction while foregrounded. |
The grace window matters: presenting the biometric prompt can briefly background the WebView. Without it the app would lock itself in a loop. While a lock is shown or an unlock is in flight, background transitions are ignored.
Safety rule: never trap the user
The lock only arms when the device actually has a biometric/credential enrolled. If
none exists, the app does not lock (otherwise every background would force a
full logout). The unlock screen always offers “Entrar com senha” → logout →
/login as an escape hatch.
Lock screen anatomy
┌─────────────────────────────┐
│ │
│ [ Velon logo ] │ white wordmark on velon-navy
│ │
│ App bloqueado │
│ Use sua biometria para │
│ desbloquear e continuar. │
│ │
│ ( 👆 ) │ fingerprint button → biometric prompt
│ Desbloquear │
│ │
│ Entrar com senha │ → logout → /login
└─────────────────────────────┘- Full-screen
fixed inset-0 z-[100]overlay onbg-velon-navy, padded with both safe-area insets. It must cover the bottom nav (hence the high z-index). - A failed attempt shows an inline error; a cancelled attempt does not (the user chose to dismiss).
State model
Lock state is pure and client-only (unit-tested). Only the user’s enable
preference persists across restarts. isLocked, backgroundedAt and the
in-flight flag are runtime.
isLockEffectivelyEnabled(pref, available)→available && (pref ?? true): default-on when a biometric exists, overridable by an explicit user preference.shouldLockOnForeground(backgroundedAt, now)→now - backgroundedAt >= grace.
No backend is involved: the biometric check is local and unlock only flips client state. Logout reuses the existing session-logout endpoint.
Logout
The app exposes a Sair row at the bottom of the mobile Profile menu (destructive
styling). It calls the auth store logout, then hard-redirects to /login (with a
local-storage clear + Supabase sign-out fallback) so all in-memory and persisted
state is dropped.
Enrollment (planned)
After the first OTP login on native, prompt once: “Ativar desbloqueio por biometria?”, plus a toggle under Segurança to opt out. Until then the core defaults to on whenever a biometric is available. Tracked in Velon-user#545.