feat(mobile): biometric sign-in (Face ID / Touch ID / fingerprint)
Adds opt-in biometric login to the Capacitor app. Replaces the password step on subsequent sign-ins; the 2FA step (if any) still applies — by design, defense in depth. How it works: - After a successful password sign-in on a Capacitor build, prompt the user to enroll. If they accept, capacitor-native-biometric.setCredentials stores the (email, password) pair in the iOS Keychain / Android Keystore with biometric-protected access. The local flag ped_bio_enabled=1 is set so the next launch knows to probe. - On the login form, if isNativeApp() + bioStored() + bioAvailable.ok, reveal the "Sign in with Face ID / Touch ID / fingerprint" button at the top. Label is set from the actual biometryType returned by the plugin so users see what their device supports. - Tap → verifyIdentity (OS prompt) → getCredentials → fill the email + password fields → fire the existing form submit so all the regular flow runs (turnstile, 2FA prompt, error handling, session storage). - Explicit logout deletes credentials AND clears the local flag, hiding the button on the next visit. Auto-logout (token expiry, network) does NOT come through that path, so biometric persists across silent session resets. Storage choice — password not JWT: - JWTs expire and the storage would constantly need refresh. - Storing the password lets the standard /api/auth/login flow run, which already handles password-rotation (a stale stored password just fails 401 → user falls back to typing the new one → re-enrolls). - The password sits in OS-level secure storage, accessible only after successful biometric verification — same security posture as a password manager autofill. Files: - mobile/package.json: add capacitor-native-biometric@^5.0.0 (Capacitor 6 compat) - mobile/android/app/src/main/AndroidManifest.xml: add USE_BIOMETRIC uses-permission - mobile/ios/App/App/Info.plist: add NSFaceIDUsageDescription string - public/js/auth.js: bioPlugin/bioAvailable/bioStored/bioEnroll/ bioRetrieve/bioForget helpers; window.PedBio surface; reveal-on-load; click handler; post-login enrollment prompt; logout cleanup - public/index.html: hidden #btn-bio-login + #bio-divider above the email field on the login form - public/css/styles.css: themed gradient button + hover lift - mobile/README.md: feature list updated Build steps for Daniel: cd mobile && npm install # picks up capacitor-native-biometric npx cap sync # ports the plugin into android/ + ios/ # then build APK / IPA as usual
This commit is contained in:
parent
503f5afaad
commit
8f49c4dcb9
7 changed files with 179 additions and 0 deletions
|
|
@ -11,6 +11,10 @@ Native mobile wrapper for Pediatric AI Scribe using Capacitor. Provides backgrou
|
|||
- Deep linking (pedscribe:// and https://app.pedshub.com)
|
||||
- Share intent (receive text/PDFs from other apps)
|
||||
- Push notification support
|
||||
- **Biometric sign-in** (Face ID / Touch ID / fingerprint) — credentials
|
||||
stored in iOS Keychain / Android Keystore, gated by OS biometric.
|
||||
Enrolled on first password sign-in (opt-in prompt). 2FA still applies
|
||||
on top — biometric replaces the password step only.
|
||||
- App Store and Play Store ready
|
||||
|
||||
## Prerequisites
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Biometric login (capacitor-native-biometric). USE_BIOMETRIC is the
|
||||
API 28+ permission; older devices ignore it. No legacy FINGERPRINT
|
||||
entry needed because capacitor-native-biometric targets API 23+. -->
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>PedScribe</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>PedScribe uses Face ID to securely sign you in without re-entering your password.</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"@capacitor/share": "^6.0.0",
|
||||
"@capacitor/splash-screen": "^6.0.0",
|
||||
"@capacitor/status-bar": "^6.0.0",
|
||||
"capacitor-native-biometric": "^5.0.0",
|
||||
"capacitor-secure-storage-plugin": "^0.10.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1146,3 +1146,17 @@ textarea.full-input{resize:vertical;}
|
|||
.docs-sidebar{flex:0 0 auto;max-height:240px;}
|
||||
.docs-reader{min-height:60vh;}
|
||||
}
|
||||
|
||||
/* Biometric login button on the auth screen — themed to match the SSO
|
||||
button in restraint, but with a friendly accent so it doesn't look
|
||||
like a duplicate of the password Sign In button. */
|
||||
.btn-bio-login{
|
||||
background:linear-gradient(135deg,#0ea5e9,#2563eb);
|
||||
color:white;
|
||||
margin-bottom:8px;
|
||||
display:flex;align-items:center;justify-content:center;gap:8px;
|
||||
transition:transform .12s ease, box-shadow .15s ease;
|
||||
}
|
||||
.btn-bio-login:hover{transform:translateY(-1px);box-shadow:0 4px 12px -3px rgba(37,99,235,.45);}
|
||||
.btn-bio-login:active{transform:translateY(0);}
|
||||
.btn-bio-login i{font-size:18px;}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,16 @@
|
|||
<!-- Login Form -->
|
||||
<form id="login-form" class="auth-form" autocomplete="off">
|
||||
<h2>Sign In</h2>
|
||||
<!-- Biometric quick-login (Capacitor only). Hidden by default;
|
||||
revealed by auth.js when isNativeApp() + biometric available
|
||||
+ credentials previously enrolled. -->
|
||||
<button type="button" id="btn-bio-login" class="btn-auth btn-bio-login hidden" style="display:none;">
|
||||
<i class="fas fa-fingerprint"></i> <span id="bio-login-label">Sign in with biometric</span>
|
||||
</button>
|
||||
<div id="bio-divider" class="hidden" style="display:none;text-align:center;margin:6px 0 12px;position:relative;">
|
||||
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or use password</span>
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;position:absolute;top:50%;left:0;right:0;margin:0;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="login-email" required placeholder="your@email.com">
|
||||
|
|
|
|||
|
|
@ -39,11 +39,90 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Biometric login (Capacitor only) ────────────────────────────────
|
||||
// Uses capacitor-native-biometric to gate stored credentials behind
|
||||
// Face ID / Touch ID / fingerprint. Server is identity (email);
|
||||
// server-side credential is the user's password (not the JWT — JWTs
|
||||
// expire and would force a fallback password login to refresh).
|
||||
// 2FA still applies on top: biometric replaces the password step but
|
||||
// a 2FA-enabled account still gets the TOTP prompt afterwards. That
|
||||
// is the intended defense-in-depth.
|
||||
var BIO_SERVER = 'pedscribe-bio'; // namespace for the keychain/keystore item
|
||||
var BIO_ENABLED_KEY = 'ped_bio_enabled'; // localStorage flag — used to decide whether to even probe
|
||||
function bioPlugin() {
|
||||
try {
|
||||
if (!isNativeApp()) return null;
|
||||
var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.NativeBiometric;
|
||||
return p || null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function bioAvailable() {
|
||||
var p = bioPlugin();
|
||||
if (!p) return Promise.resolve({ ok: false });
|
||||
return p.isAvailable()
|
||||
.then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; })
|
||||
.catch(function () { return { ok: false }; });
|
||||
}
|
||||
function bioStored() {
|
||||
// Cheap check first — was biometric ever enrolled? If not, skip the
|
||||
// verifyIdentity prompt path entirely so we don't rattle the user.
|
||||
try { return localStorage.getItem(BIO_ENABLED_KEY) === '1'; } catch (e) { return false; }
|
||||
}
|
||||
function bioEnroll(email, password) {
|
||||
var p = bioPlugin();
|
||||
if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
|
||||
return p.setCredentials({ username: email, password: password, server: BIO_SERVER })
|
||||
.then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} });
|
||||
}
|
||||
function bioRetrieve() {
|
||||
var p = bioPlugin();
|
||||
if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
|
||||
return p.verifyIdentity({
|
||||
reason: 'Sign in to PedScribe',
|
||||
title: 'PedScribe',
|
||||
subtitle: 'Use biometric to sign in',
|
||||
description: 'Confirm your identity to continue.'
|
||||
}).then(function () {
|
||||
return p.getCredentials({ server: BIO_SERVER });
|
||||
});
|
||||
}
|
||||
function bioForget() {
|
||||
var p = bioPlugin();
|
||||
try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {}
|
||||
if (!p) return Promise.resolve();
|
||||
return p.deleteCredentials({ server: BIO_SERVER }).catch(function () { /* fine if missing */ });
|
||||
}
|
||||
// Expose a small surface so settings/logout/etc can call into it.
|
||||
window.PedBio = { available: bioAvailable, stored: bioStored, enroll: bioEnroll, retrieve: bioRetrieve, forget: bioForget };
|
||||
|
||||
// Auth module initialized
|
||||
|
||||
// Make sure forms are visible/hidden correctly on load
|
||||
showLoginForm();
|
||||
|
||||
// Biometric login button: reveal on the login form when the device
|
||||
// supports it AND the user has previously enrolled. Fire-and-forget;
|
||||
// any failure (no plugin, locked out, hardware missing) just leaves
|
||||
// the button hidden.
|
||||
function maybeRevealBioButton() {
|
||||
var btn = document.getElementById('btn-bio-login');
|
||||
var div = document.getElementById('bio-divider');
|
||||
if (!btn || !isNativeApp()) return;
|
||||
if (!bioStored()) return;
|
||||
bioAvailable().then(function (s) {
|
||||
if (!s.ok) return;
|
||||
// Tweak the label to the actual biometry type when known.
|
||||
var label = document.getElementById('bio-login-label');
|
||||
if (label && s.type) {
|
||||
var typeMap = { 'FACE_ID': 'Sign in with Face ID', 'TOUCH_ID': 'Sign in with Touch ID', 'FACE_AUTHENTICATION': 'Sign in with face recognition', 'FINGERPRINT': 'Sign in with fingerprint' };
|
||||
label.textContent = typeMap[s.type] || 'Sign in with biometric';
|
||||
}
|
||||
btn.classList.remove('hidden'); btn.style.display = '';
|
||||
if (div) { div.classList.remove('hidden'); div.style.display = ''; }
|
||||
});
|
||||
}
|
||||
maybeRevealBioButton();
|
||||
|
||||
// Auth screen is hidden by CSS default — only show it when there is no valid session
|
||||
function showAuthScreen() {
|
||||
if (authScreen) authScreen.style.display = 'flex';
|
||||
|
|
@ -230,6 +309,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (adminTabBtn) adminTabBtn.classList.add('hidden');
|
||||
var docsTabBtn = document.getElementById('docs-tab-btn');
|
||||
if (docsTabBtn) docsTabBtn.classList.add('hidden');
|
||||
// Explicit logout clears biometric — assume the user is leaving the
|
||||
// device for someone else. Auto-logout (token expiry, network) does
|
||||
// NOT come through this path, so biometric persists across silent
|
||||
// session resets.
|
||||
if (window.PedBio && typeof window.PedBio.forget === 'function') {
|
||||
window.PedBio.forget();
|
||||
}
|
||||
var bioBtn = document.getElementById('btn-bio-login');
|
||||
if (bioBtn) { bioBtn.classList.add('hidden'); bioBtn.style.display = 'none'; }
|
||||
var bioDiv = document.getElementById('bio-divider');
|
||||
if (bioDiv) { bioDiv.classList.add('hidden'); bioDiv.style.display = 'none'; }
|
||||
showLoginForm();
|
||||
// Clear fields after browser autofill has had a chance to run
|
||||
setTimeout(function() {
|
||||
|
|
@ -434,6 +524,43 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
});
|
||||
|
||||
// ---- BIOMETRIC LOGIN BUTTON ----
|
||||
// Reads the email + password from the OS-secured keychain (gated behind
|
||||
// Face ID / Touch ID / fingerprint) and fills the login form. Submits the
|
||||
// form so all the existing flow (turnstile, 2FA prompt, error handling,
|
||||
// session storage) runs unchanged. If biometric verification fails, the
|
||||
// user just gets a toast and falls through to typing the password.
|
||||
var bioBtn = document.getElementById('btn-bio-login');
|
||||
if (bioBtn) {
|
||||
bioBtn.addEventListener('click', function () {
|
||||
bioRetrieve()
|
||||
.then(function (creds) {
|
||||
if (!creds || !creds.username || !creds.password) {
|
||||
showToast('No stored credentials', 'error');
|
||||
return;
|
||||
}
|
||||
var emailEl = document.getElementById('login-email');
|
||||
var pwEl = document.getElementById('login-password');
|
||||
if (emailEl) emailEl.value = creds.username;
|
||||
if (pwEl) pwEl.value = creds.password;
|
||||
// Trigger the same submit path as the password form so all the
|
||||
// existing handling (turnstile token, 2FA, session storage, etc.)
|
||||
// runs unchanged. If turnstile hasn't auto-solved yet the form
|
||||
// will toast "Please complete the verification" — same as a
|
||||
// manual login attempt before turnstile resolves.
|
||||
if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit();
|
||||
else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
})
|
||||
.catch(function (err) {
|
||||
// User cancelled or biometric failed (locked out, no enrolled
|
||||
// biometric, etc). Stay quiet for cancel; toast for hard errors.
|
||||
var msg = (err && (err.message || err.code)) || '';
|
||||
if (/cancel/i.test(msg)) return;
|
||||
showToast('Biometric sign-in failed', 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- LOGIN FORM SUBMIT ----
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', function(e) {
|
||||
|
|
@ -492,6 +619,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
enterApp(data.user, data.token);
|
||||
showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||
// Offer biometric enrollment after the very first successful
|
||||
// password login on a Capacitor device. Only ask once per
|
||||
// (device, account) — enrollment flips the BIO_ENABLED_KEY flag.
|
||||
if (isNativeApp() && !bioStored()) {
|
||||
bioAvailable().then(function (s) {
|
||||
if (!s.ok) return;
|
||||
var typeName = ({ 'FACE_ID': 'Face ID', 'TOUCH_ID': 'Touch ID', 'FACE_AUTHENTICATION': 'face recognition', 'FINGERPRINT': 'fingerprint' })[s.type] || 'biometric';
|
||||
if (typeof showConfirm === 'function') {
|
||||
showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () {
|
||||
bioEnroll(email, password)
|
||||
.then(function () { showToast(typeName + ' enabled. Use it next time you sign in.', 'success'); })
|
||||
.catch(function () { showToast('Could not enable ' + typeName, 'error'); });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showToast(data.error || 'Login failed', 'error');
|
||||
if (window.turnstile) turnstile.reset('#turnstile-login');
|
||||
|
|
|
|||
Loading…
Reference in a new issue