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)
|
- Deep linking (pedscribe:// and https://app.pedshub.com)
|
||||||
- Share intent (receive text/PDFs from other apps)
|
- Share intent (receive text/PDFs from other apps)
|
||||||
- Push notification support
|
- 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
|
- App Store and Play Store ready
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:fullBackupContent="false"
|
android:fullBackupContent="false"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>PedScribe</string>
|
<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>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"@capacitor/share": "^6.0.0",
|
"@capacitor/share": "^6.0.0",
|
||||||
"@capacitor/splash-screen": "^6.0.0",
|
"@capacitor/splash-screen": "^6.0.0",
|
||||||
"@capacitor/status-bar": "^6.0.0",
|
"@capacitor/status-bar": "^6.0.0",
|
||||||
|
"capacitor-native-biometric": "^5.0.0",
|
||||||
"capacitor-secure-storage-plugin": "^0.10.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-sidebar{flex:0 0 auto;max-height:240px;}
|
||||||
.docs-reader{min-height:60vh;}
|
.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 -->
|
<!-- Login Form -->
|
||||||
<form id="login-form" class="auth-form" autocomplete="off">
|
<form id="login-form" class="auth-form" autocomplete="off">
|
||||||
<h2>Sign In</h2>
|
<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">
|
<div class="form-group">
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<input type="email" id="login-email" required placeholder="your@email.com">
|
<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
|
// Auth module initialized
|
||||||
|
|
||||||
// Make sure forms are visible/hidden correctly on load
|
// Make sure forms are visible/hidden correctly on load
|
||||||
showLoginForm();
|
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
|
// Auth screen is hidden by CSS default — only show it when there is no valid session
|
||||||
function showAuthScreen() {
|
function showAuthScreen() {
|
||||||
if (authScreen) authScreen.style.display = 'flex';
|
if (authScreen) authScreen.style.display = 'flex';
|
||||||
|
|
@ -230,6 +309,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (adminTabBtn) adminTabBtn.classList.add('hidden');
|
if (adminTabBtn) adminTabBtn.classList.add('hidden');
|
||||||
var docsTabBtn = document.getElementById('docs-tab-btn');
|
var docsTabBtn = document.getElementById('docs-tab-btn');
|
||||||
if (docsTabBtn) docsTabBtn.classList.add('hidden');
|
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();
|
showLoginForm();
|
||||||
// Clear fields after browser autofill has had a chance to run
|
// Clear fields after browser autofill has had a chance to run
|
||||||
setTimeout(function() {
|
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 ----
|
// ---- LOGIN FORM SUBMIT ----
|
||||||
if (loginForm) {
|
if (loginForm) {
|
||||||
loginForm.addEventListener('submit', function(e) {
|
loginForm.addEventListener('submit', function(e) {
|
||||||
|
|
@ -492,6 +619,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
enterApp(data.user, data.token);
|
enterApp(data.user, data.token);
|
||||||
showToast('Welcome, ' + data.user.name + '!', 'success');
|
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 {
|
} else {
|
||||||
showToast(data.error || 'Login failed', 'error');
|
showToast(data.error || 'Login failed', 'error');
|
||||||
if (window.turnstile) turnstile.reset('#turnstile-login');
|
if (window.turnstile) turnstile.reset('#turnstile-login');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue