diff --git a/mobile/android/app/src/main/assets/public/launcher.js b/mobile/android/app/src/main/assets/public/launcher.js
index 5f68d33..13fce67 100644
--- a/mobile/android/app/src/main/assets/public/launcher.js
+++ b/mobile/android/app/src/main/assets/public/launcher.js
@@ -1,39 +1,51 @@
// PedScribe Mobile Launcher
-// Handles configurable server URL, auto-redirect, and native features
+// Handles server URL config, biometric auth, and native features
(function() {
var STORAGE_KEY = 'pedscribe_server_url';
+ var BIOMETRIC_KEY = 'pedscribe_biometric_enabled';
var DEFAULT_URL = 'https://app.pedshub.com';
var setupScreen = document.getElementById('setup-screen');
var connectingScreen = document.getElementById('connecting-screen');
+ var biometricScreen = document.getElementById('biometric-screen');
var urlInput = document.getElementById('server-url');
var connectBtn = document.getElementById('btn-connect');
var changeBtn = document.getElementById('btn-change-server');
+ var unlockBtn = document.getElementById('btn-unlock');
+ var skipBioBtn = document.getElementById('btn-skip-biometric');
- // Check for saved server URL
var savedUrl = localStorage.getItem(STORAGE_KEY);
+ var biometricEnabled = localStorage.getItem(BIOMETRIC_KEY) === 'true';
+ // ── Launch flow ──
if (savedUrl) {
- showConnecting(savedUrl);
+ if (biometricEnabled && isCapacitor()) {
+ showBiometricScreen(savedUrl);
+ } else {
+ showConnecting(savedUrl);
+ }
} else {
urlInput.value = DEFAULT_URL;
- setupScreen.style.display = '';
- connectingScreen.style.display = 'none';
+ showScreen('setup');
}
- // Connect button
+ // ── Connect button ──
connectBtn.addEventListener('click', function() {
var url = (urlInput.value || DEFAULT_URL).trim().replace(/\/+$/, '');
if (!url.startsWith('http')) url = 'https://' + url;
connectBtn.disabled = true;
connectBtn.textContent = 'Connecting...';
- hapticFeedback();
+ haptic();
testServer(url, function(ok) {
if (ok) {
localStorage.setItem(STORAGE_KEY, url);
+ // Enable biometric on first connect if available
+ checkBiometricAvailable(function(available) {
+ if (available) localStorage.setItem(BIOMETRIC_KEY, 'true');
+ });
navigateToServer(url);
} else {
connectBtn.disabled = false;
@@ -43,38 +55,71 @@
});
});
- // Enter key
urlInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') connectBtn.click();
});
- // Change server button
+ // ── Change server ──
changeBtn.addEventListener('click', function() {
localStorage.removeItem(STORAGE_KEY);
- setupScreen.style.display = '';
- connectingScreen.style.display = 'none';
urlInput.value = savedUrl || DEFAULT_URL;
+ showScreen('setup');
urlInput.focus();
});
- function showConnecting(url) {
- setupScreen.style.display = 'none';
- connectingScreen.style.display = '';
+ // ── Biometric unlock ──
+ if (unlockBtn) {
+ unlockBtn.addEventListener('click', function() {
+ doBiometricAuth(function(ok) {
+ if (ok) {
+ showConnecting(savedUrl);
+ } else {
+ showError('Authentication failed. Try again.');
+ }
+ });
+ });
+ }
+ if (skipBioBtn) {
+ skipBioBtn.addEventListener('click', function() {
+ showConnecting(savedUrl);
+ });
+ }
+
+ // ── Screen management ──
+ function showScreen(which) {
+ setupScreen.style.display = which === 'setup' ? '' : 'none';
+ connectingScreen.style.display = which === 'connecting' ? '' : 'none';
+ biometricScreen.style.display = which === 'biometric' ? '' : 'none';
+ }
+
+ function showBiometricScreen(url) {
+ showScreen('biometric');
+ // Auto-trigger biometric prompt
+ setTimeout(function() {
+ doBiometricAuth(function(ok) {
+ if (ok) showConnecting(url);
+ // If fails, user sees the manual Unlock button
+ });
+ }, 300);
+ }
+
+ function showConnecting(url) {
+ showScreen('connecting');
setTimeout(function() {
testServer(url, function(ok) {
if (ok) {
navigateToServer(url);
} else {
- setupScreen.style.display = '';
- connectingScreen.style.display = 'none';
urlInput.value = url;
+ showScreen('setup');
showError('Server not reachable. Check your connection or change the URL.');
}
});
}, 500);
}
+ // ── Server check ──
function testServer(url, callback) {
var done = false;
function respond(ok) {
@@ -98,6 +143,52 @@
window.location.href = url;
}
+ // ── Biometric auth ──
+ function isCapacitor() {
+ return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform());
+ }
+
+ function checkBiometricAvailable(callback) {
+ if (!isCapacitor()) { callback(false); return; }
+ try {
+ var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
+ if (!BiometricAuth) { callback(false); return; }
+ BiometricAuth.checkBiometry().then(function(result) {
+ callback(result.isAvailable);
+ }).catch(function() { callback(false); });
+ } catch(e) { callback(false); }
+ }
+
+ function doBiometricAuth(callback) {
+ if (!isCapacitor()) { callback(true); return; } // Skip on web
+ try {
+ var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
+ if (!BiometricAuth) { callback(true); return; }
+ BiometricAuth.authenticate({
+ reason: 'Unlock PedScribe',
+ cancelTitle: 'Cancel',
+ allowDeviceCredential: true, // Allow PIN/password fallback
+ iosFallbackTitle: 'Use Passcode',
+ androidTitle: 'PedScribe',
+ androidSubtitle: 'Verify your identity to continue',
+ androidConfirmationRequired: false
+ }).then(function() {
+ callback(true);
+ }).catch(function() {
+ callback(false);
+ });
+ } catch(e) { callback(true); } // Fail open if plugin not available
+ }
+
+ // ── Helpers ──
+ function haptic() {
+ try {
+ if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
+ window.Capacitor.Plugins.Haptics.impact({ style: 'medium' });
+ }
+ } catch(e) {}
+ }
+
function showError(msg) {
var existing = document.querySelector('.error-msg');
if (existing) existing.remove();
@@ -105,14 +196,8 @@
div.className = 'error-msg';
div.style.display = 'block';
div.textContent = msg;
- connectBtn.parentNode.insertBefore(div, connectBtn.nextSibling);
+ var parent = connectBtn ? connectBtn.parentNode : document.querySelector('.launcher');
+ if (parent) parent.appendChild(div);
setTimeout(function() { if (div.parentNode) div.remove(); }, 8000);
}
-
- // Haptic feedback (Capacitor native)
- function hapticFeedback() {
- if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
- window.Capacitor.Plugins.Haptics.impact({ style: 'medium' });
- }
- }
})();
diff --git a/mobile/android/capacitor.settings.gradle b/mobile/android/capacitor.settings.gradle
index 27824ed..eb6bbc7 100644
--- a/mobile/android/capacitor.settings.gradle
+++ b/mobile/android/capacitor.settings.gradle
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
+include ':aparajita-capacitor-biometric-auth'
+project(':aparajita-capacitor-biometric-auth').projectDir = new File('../node_modules/@aparajita/capacitor-biometric-auth/android')
+
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
diff --git a/mobile/ios/App/App/capacitor.config.json b/mobile/ios/App/App/capacitor.config.json
index 968dd32..583c488 100644
--- a/mobile/ios/App/App/capacitor.config.json
+++ b/mobile/ios/App/App/capacitor.config.json
@@ -36,6 +36,7 @@
},
"packageClassList": [
"AppPlugin",
+ "BiometricAuthNative",
"HapticsPlugin",
"KeyboardPlugin",
"PushNotificationsPlugin",
diff --git a/mobile/ios/App/App/public/index.html b/mobile/ios/App/App/public/index.html
index 3d285dd..2936fd2 100644
--- a/mobile/ios/App/App/public/index.html
+++ b/mobile/ios/App/App/public/index.html
@@ -23,6 +23,23 @@
diff --git a/mobile/ios/App/App/public/launcher.js b/mobile/ios/App/App/public/launcher.js
index 5f68d33..13fce67 100644
--- a/mobile/ios/App/App/public/launcher.js
+++ b/mobile/ios/App/App/public/launcher.js
@@ -1,39 +1,51 @@
// PedScribe Mobile Launcher
-// Handles configurable server URL, auto-redirect, and native features
+// Handles server URL config, biometric auth, and native features
(function() {
var STORAGE_KEY = 'pedscribe_server_url';
+ var BIOMETRIC_KEY = 'pedscribe_biometric_enabled';
var DEFAULT_URL = 'https://app.pedshub.com';
var setupScreen = document.getElementById('setup-screen');
var connectingScreen = document.getElementById('connecting-screen');
+ var biometricScreen = document.getElementById('biometric-screen');
var urlInput = document.getElementById('server-url');
var connectBtn = document.getElementById('btn-connect');
var changeBtn = document.getElementById('btn-change-server');
+ var unlockBtn = document.getElementById('btn-unlock');
+ var skipBioBtn = document.getElementById('btn-skip-biometric');
- // Check for saved server URL
var savedUrl = localStorage.getItem(STORAGE_KEY);
+ var biometricEnabled = localStorage.getItem(BIOMETRIC_KEY) === 'true';
+ // ── Launch flow ──
if (savedUrl) {
- showConnecting(savedUrl);
+ if (biometricEnabled && isCapacitor()) {
+ showBiometricScreen(savedUrl);
+ } else {
+ showConnecting(savedUrl);
+ }
} else {
urlInput.value = DEFAULT_URL;
- setupScreen.style.display = '';
- connectingScreen.style.display = 'none';
+ showScreen('setup');
}
- // Connect button
+ // ── Connect button ──
connectBtn.addEventListener('click', function() {
var url = (urlInput.value || DEFAULT_URL).trim().replace(/\/+$/, '');
if (!url.startsWith('http')) url = 'https://' + url;
connectBtn.disabled = true;
connectBtn.textContent = 'Connecting...';
- hapticFeedback();
+ haptic();
testServer(url, function(ok) {
if (ok) {
localStorage.setItem(STORAGE_KEY, url);
+ // Enable biometric on first connect if available
+ checkBiometricAvailable(function(available) {
+ if (available) localStorage.setItem(BIOMETRIC_KEY, 'true');
+ });
navigateToServer(url);
} else {
connectBtn.disabled = false;
@@ -43,38 +55,71 @@
});
});
- // Enter key
urlInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') connectBtn.click();
});
- // Change server button
+ // ── Change server ──
changeBtn.addEventListener('click', function() {
localStorage.removeItem(STORAGE_KEY);
- setupScreen.style.display = '';
- connectingScreen.style.display = 'none';
urlInput.value = savedUrl || DEFAULT_URL;
+ showScreen('setup');
urlInput.focus();
});
- function showConnecting(url) {
- setupScreen.style.display = 'none';
- connectingScreen.style.display = '';
+ // ── Biometric unlock ──
+ if (unlockBtn) {
+ unlockBtn.addEventListener('click', function() {
+ doBiometricAuth(function(ok) {
+ if (ok) {
+ showConnecting(savedUrl);
+ } else {
+ showError('Authentication failed. Try again.');
+ }
+ });
+ });
+ }
+ if (skipBioBtn) {
+ skipBioBtn.addEventListener('click', function() {
+ showConnecting(savedUrl);
+ });
+ }
+
+ // ── Screen management ──
+ function showScreen(which) {
+ setupScreen.style.display = which === 'setup' ? '' : 'none';
+ connectingScreen.style.display = which === 'connecting' ? '' : 'none';
+ biometricScreen.style.display = which === 'biometric' ? '' : 'none';
+ }
+
+ function showBiometricScreen(url) {
+ showScreen('biometric');
+ // Auto-trigger biometric prompt
+ setTimeout(function() {
+ doBiometricAuth(function(ok) {
+ if (ok) showConnecting(url);
+ // If fails, user sees the manual Unlock button
+ });
+ }, 300);
+ }
+
+ function showConnecting(url) {
+ showScreen('connecting');
setTimeout(function() {
testServer(url, function(ok) {
if (ok) {
navigateToServer(url);
} else {
- setupScreen.style.display = '';
- connectingScreen.style.display = 'none';
urlInput.value = url;
+ showScreen('setup');
showError('Server not reachable. Check your connection or change the URL.');
}
});
}, 500);
}
+ // ── Server check ──
function testServer(url, callback) {
var done = false;
function respond(ok) {
@@ -98,6 +143,52 @@
window.location.href = url;
}
+ // ── Biometric auth ──
+ function isCapacitor() {
+ return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform());
+ }
+
+ function checkBiometricAvailable(callback) {
+ if (!isCapacitor()) { callback(false); return; }
+ try {
+ var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
+ if (!BiometricAuth) { callback(false); return; }
+ BiometricAuth.checkBiometry().then(function(result) {
+ callback(result.isAvailable);
+ }).catch(function() { callback(false); });
+ } catch(e) { callback(false); }
+ }
+
+ function doBiometricAuth(callback) {
+ if (!isCapacitor()) { callback(true); return; } // Skip on web
+ try {
+ var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
+ if (!BiometricAuth) { callback(true); return; }
+ BiometricAuth.authenticate({
+ reason: 'Unlock PedScribe',
+ cancelTitle: 'Cancel',
+ allowDeviceCredential: true, // Allow PIN/password fallback
+ iosFallbackTitle: 'Use Passcode',
+ androidTitle: 'PedScribe',
+ androidSubtitle: 'Verify your identity to continue',
+ androidConfirmationRequired: false
+ }).then(function() {
+ callback(true);
+ }).catch(function() {
+ callback(false);
+ });
+ } catch(e) { callback(true); } // Fail open if plugin not available
+ }
+
+ // ── Helpers ──
+ function haptic() {
+ try {
+ if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
+ window.Capacitor.Plugins.Haptics.impact({ style: 'medium' });
+ }
+ } catch(e) {}
+ }
+
function showError(msg) {
var existing = document.querySelector('.error-msg');
if (existing) existing.remove();
@@ -105,14 +196,8 @@
div.className = 'error-msg';
div.style.display = 'block';
div.textContent = msg;
- connectBtn.parentNode.insertBefore(div, connectBtn.nextSibling);
+ var parent = connectBtn ? connectBtn.parentNode : document.querySelector('.launcher');
+ if (parent) parent.appendChild(div);
setTimeout(function() { if (div.parentNode) div.remove(); }, 8000);
}
-
- // Haptic feedback (Capacitor native)
- function hapticFeedback() {
- if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
- window.Capacitor.Plugins.Haptics.impact({ style: 'medium' });
- }
- }
})();
diff --git a/mobile/ios/App/Podfile b/mobile/ios/App/Podfile
index 26c5673..458974d 100644
--- a/mobile/ios/App/Podfile
+++ b/mobile/ios/App/Podfile
@@ -12,6 +12,7 @@ def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
+ pod 'AparajitaCapacitorBiometricAuth', :path => '../../node_modules/@aparajita/capacitor-biometric-auth'
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
diff --git a/mobile/node_modules/.package-lock.json b/mobile/node_modules/.package-lock.json
index e615d3f..51084f7 100644
--- a/mobile/node_modules/.package-lock.json
+++ b/mobile/node_modules/.package-lock.json
@@ -4,6 +4,21 @@
"lockfileVersion": 3,
"requires": true,
"packages": {
+ "node_modules/@aparajita/capacitor-biometric-auth": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@aparajita/capacitor-biometric-auth/-/capacitor-biometric-auth-8.0.2.tgz",
+ "integrity": "sha512-f5EHwJYkwQDM/FmoZoyOTmBs/3XEcYURZDUNyps9+Fn7WrgojtJuZP8GhfW7KrQGqCok+ctDd369nEAmNiTxfw==",
+ "license": "MIT",
+ "dependencies": {
+ "@capacitor/android": "^6.1.0",
+ "@capacitor/app": "^6.0.0",
+ "@capacitor/core": "^6.1.0",
+ "@capacitor/ios": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@capacitor/android": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-6.2.1.tgz",
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index e89429a..8b5d7e4 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -8,6 +8,7 @@
"name": "pedscribe-mobile",
"version": "1.0.0",
"dependencies": {
+ "@aparajita/capacitor-biometric-auth": "^8.0.0",
"@capacitor/android": "^6.0.0",
"@capacitor/app": "^6.0.0",
"@capacitor/cli": "^6.0.0",
@@ -22,6 +23,21 @@
"@capacitor/status-bar": "^6.0.0"
}
},
+ "node_modules/@aparajita/capacitor-biometric-auth": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@aparajita/capacitor-biometric-auth/-/capacitor-biometric-auth-8.0.2.tgz",
+ "integrity": "sha512-f5EHwJYkwQDM/FmoZoyOTmBs/3XEcYURZDUNyps9+Fn7WrgojtJuZP8GhfW7KrQGqCok+ctDd369nEAmNiTxfw==",
+ "license": "MIT",
+ "dependencies": {
+ "@capacitor/android": "^6.1.0",
+ "@capacitor/app": "^6.0.0",
+ "@capacitor/core": "^6.1.0",
+ "@capacitor/ios": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@capacitor/android": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-6.2.1.tgz",
diff --git a/mobile/package.json b/mobile/package.json
index 2944f24..163e7c5 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -16,6 +16,7 @@
"@capacitor/cli": "^6.0.0",
"@capacitor/core": "^6.0.0",
"@capacitor/ios": "^6.0.0",
+ "@aparajita/capacitor-biometric-auth": "^8.0.0",
"@capacitor/haptics": "^6.0.0",
"@capacitor/keyboard": "^6.0.0",
"@capacitor/push-notifications": "^6.0.0",
diff --git a/mobile/src/index.html b/mobile/src/index.html
index 3d285dd..2936fd2 100644
--- a/mobile/src/index.html
+++ b/mobile/src/index.html
@@ -23,6 +23,23 @@
+
+
+
+
PedScribe
+
Unlock to continue
+
+
+
+
diff --git a/mobile/src/launcher.js b/mobile/src/launcher.js
index 5f68d33..13fce67 100644
--- a/mobile/src/launcher.js
+++ b/mobile/src/launcher.js
@@ -1,39 +1,51 @@
// PedScribe Mobile Launcher
-// Handles configurable server URL, auto-redirect, and native features
+// Handles server URL config, biometric auth, and native features
(function() {
var STORAGE_KEY = 'pedscribe_server_url';
+ var BIOMETRIC_KEY = 'pedscribe_biometric_enabled';
var DEFAULT_URL = 'https://app.pedshub.com';
var setupScreen = document.getElementById('setup-screen');
var connectingScreen = document.getElementById('connecting-screen');
+ var biometricScreen = document.getElementById('biometric-screen');
var urlInput = document.getElementById('server-url');
var connectBtn = document.getElementById('btn-connect');
var changeBtn = document.getElementById('btn-change-server');
+ var unlockBtn = document.getElementById('btn-unlock');
+ var skipBioBtn = document.getElementById('btn-skip-biometric');
- // Check for saved server URL
var savedUrl = localStorage.getItem(STORAGE_KEY);
+ var biometricEnabled = localStorage.getItem(BIOMETRIC_KEY) === 'true';
+ // ── Launch flow ──
if (savedUrl) {
- showConnecting(savedUrl);
+ if (biometricEnabled && isCapacitor()) {
+ showBiometricScreen(savedUrl);
+ } else {
+ showConnecting(savedUrl);
+ }
} else {
urlInput.value = DEFAULT_URL;
- setupScreen.style.display = '';
- connectingScreen.style.display = 'none';
+ showScreen('setup');
}
- // Connect button
+ // ── Connect button ──
connectBtn.addEventListener('click', function() {
var url = (urlInput.value || DEFAULT_URL).trim().replace(/\/+$/, '');
if (!url.startsWith('http')) url = 'https://' + url;
connectBtn.disabled = true;
connectBtn.textContent = 'Connecting...';
- hapticFeedback();
+ haptic();
testServer(url, function(ok) {
if (ok) {
localStorage.setItem(STORAGE_KEY, url);
+ // Enable biometric on first connect if available
+ checkBiometricAvailable(function(available) {
+ if (available) localStorage.setItem(BIOMETRIC_KEY, 'true');
+ });
navigateToServer(url);
} else {
connectBtn.disabled = false;
@@ -43,38 +55,71 @@
});
});
- // Enter key
urlInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') connectBtn.click();
});
- // Change server button
+ // ── Change server ──
changeBtn.addEventListener('click', function() {
localStorage.removeItem(STORAGE_KEY);
- setupScreen.style.display = '';
- connectingScreen.style.display = 'none';
urlInput.value = savedUrl || DEFAULT_URL;
+ showScreen('setup');
urlInput.focus();
});
- function showConnecting(url) {
- setupScreen.style.display = 'none';
- connectingScreen.style.display = '';
+ // ── Biometric unlock ──
+ if (unlockBtn) {
+ unlockBtn.addEventListener('click', function() {
+ doBiometricAuth(function(ok) {
+ if (ok) {
+ showConnecting(savedUrl);
+ } else {
+ showError('Authentication failed. Try again.');
+ }
+ });
+ });
+ }
+ if (skipBioBtn) {
+ skipBioBtn.addEventListener('click', function() {
+ showConnecting(savedUrl);
+ });
+ }
+
+ // ── Screen management ──
+ function showScreen(which) {
+ setupScreen.style.display = which === 'setup' ? '' : 'none';
+ connectingScreen.style.display = which === 'connecting' ? '' : 'none';
+ biometricScreen.style.display = which === 'biometric' ? '' : 'none';
+ }
+
+ function showBiometricScreen(url) {
+ showScreen('biometric');
+ // Auto-trigger biometric prompt
+ setTimeout(function() {
+ doBiometricAuth(function(ok) {
+ if (ok) showConnecting(url);
+ // If fails, user sees the manual Unlock button
+ });
+ }, 300);
+ }
+
+ function showConnecting(url) {
+ showScreen('connecting');
setTimeout(function() {
testServer(url, function(ok) {
if (ok) {
navigateToServer(url);
} else {
- setupScreen.style.display = '';
- connectingScreen.style.display = 'none';
urlInput.value = url;
+ showScreen('setup');
showError('Server not reachable. Check your connection or change the URL.');
}
});
}, 500);
}
+ // ── Server check ──
function testServer(url, callback) {
var done = false;
function respond(ok) {
@@ -98,6 +143,52 @@
window.location.href = url;
}
+ // ── Biometric auth ──
+ function isCapacitor() {
+ return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform());
+ }
+
+ function checkBiometricAvailable(callback) {
+ if (!isCapacitor()) { callback(false); return; }
+ try {
+ var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
+ if (!BiometricAuth) { callback(false); return; }
+ BiometricAuth.checkBiometry().then(function(result) {
+ callback(result.isAvailable);
+ }).catch(function() { callback(false); });
+ } catch(e) { callback(false); }
+ }
+
+ function doBiometricAuth(callback) {
+ if (!isCapacitor()) { callback(true); return; } // Skip on web
+ try {
+ var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
+ if (!BiometricAuth) { callback(true); return; }
+ BiometricAuth.authenticate({
+ reason: 'Unlock PedScribe',
+ cancelTitle: 'Cancel',
+ allowDeviceCredential: true, // Allow PIN/password fallback
+ iosFallbackTitle: 'Use Passcode',
+ androidTitle: 'PedScribe',
+ androidSubtitle: 'Verify your identity to continue',
+ androidConfirmationRequired: false
+ }).then(function() {
+ callback(true);
+ }).catch(function() {
+ callback(false);
+ });
+ } catch(e) { callback(true); } // Fail open if plugin not available
+ }
+
+ // ── Helpers ──
+ function haptic() {
+ try {
+ if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
+ window.Capacitor.Plugins.Haptics.impact({ style: 'medium' });
+ }
+ } catch(e) {}
+ }
+
function showError(msg) {
var existing = document.querySelector('.error-msg');
if (existing) existing.remove();
@@ -105,14 +196,8 @@
div.className = 'error-msg';
div.style.display = 'block';
div.textContent = msg;
- connectBtn.parentNode.insertBefore(div, connectBtn.nextSibling);
+ var parent = connectBtn ? connectBtn.parentNode : document.querySelector('.launcher');
+ if (parent) parent.appendChild(div);
setTimeout(function() { if (div.parentNode) div.remove(); }, 8000);
}
-
- // Haptic feedback (Capacitor native)
- function hapticFeedback() {
- if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
- window.Capacitor.Plugins.Haptics.impact({ style: 'medium' });
- }
- }
})();
diff --git a/src/routes/auth.js b/src/routes/auth.js
index 6829565..78e2aa0 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -8,6 +8,7 @@ const crypto = require('crypto');
const db = require('../db/database');
const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
+const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify');
// Check password against Have I Been Pwned (k-anonymity — only first 5 chars of SHA-1 sent)
async function checkPwnedPassword(password) {
@@ -217,6 +218,7 @@ router.post('/register', async (req, res) => {
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']);
+ notifyNewRegistration(email, name);
var smtpHost = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
if (!smtpHost) {
@@ -332,6 +334,9 @@ router.post('/login', async (req, res) => {
[sessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
} catch (e) { /* table may not exist yet */ }
+ // Notify user of new login (fire-and-forget)
+ notifyNewLogin(user.id, parseUserAgent(req.headers['user-agent']), req.ip);
+
res.json({
success: true, token: token, sessionId: sessionId,
user: { id: user.id, email: user.email, name: user.name, role: user.role, totp_enabled: user.totp_enabled, email_verified: user.email_verified }
@@ -454,6 +459,7 @@ router.post('/change-password', authMiddleware, async (req, res) => {
}
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, 'password_changed', req.ip]);
+ notifyPasswordChanged(req.user.id);
var resp = { success: true, message: 'Password changed. All other sessions have been logged out.' };
if (pwnedCount > 0) resp.passwordWarning = 'This password has appeared in ' + pwnedCount.toLocaleString() + ' data breaches. Consider choosing a different one.';
diff --git a/src/utils/notify.js b/src/utils/notify.js
new file mode 100644
index 0000000..bfa49d9
--- /dev/null
+++ b/src/utils/notify.js
@@ -0,0 +1,110 @@
+// ============================================================
+// NOTIFY — Push notifications via ntfy (self-hosted)
+// Sends notifications to user-specific ntfy topics
+// Topic format: pedscribe-{userId} (user subscribes in ntfy app)
+// ============================================================
+
+var NTFY_URL = process.env.NTFY_URL;
+var NTFY_TOKEN = process.env.NTFY_TOKEN;
+
+// Send a push notification to a user
+async function notifyUser(userId, title, message, opts) {
+ if (!NTFY_URL) return;
+ opts = opts || {};
+
+ var topic = 'pedscribe-user-' + userId;
+ var headers = {
+ 'Title': title,
+ 'Priority': opts.priority || '3', // 1=min, 3=default, 5=urgent
+ 'Tags': opts.tags || 'stethoscope'
+ };
+
+ if (opts.click) headers['Click'] = opts.click;
+ if (opts.actions) headers['Actions'] = opts.actions;
+ if (NTFY_TOKEN) headers['Authorization'] = 'Bearer ' + NTFY_TOKEN;
+
+ try {
+ await fetch(NTFY_URL + '/' + topic, {
+ method: 'POST',
+ headers: headers,
+ body: message
+ });
+ } catch (e) {
+ console.warn('[Notify] ntfy send failed:', e.message);
+ }
+}
+
+// Send notification to admin topic
+async function notifyAdmin(title, message, opts) {
+ if (!NTFY_URL) return;
+ opts = opts || {};
+
+ var headers = {
+ 'Title': title,
+ 'Priority': opts.priority || '3',
+ 'Tags': opts.tags || 'shield'
+ };
+ if (NTFY_TOKEN) headers['Authorization'] = 'Bearer ' + NTFY_TOKEN;
+
+ try {
+ await fetch(NTFY_URL + '/pedscribe-admin', {
+ method: 'POST',
+ headers: headers,
+ body: message
+ });
+ } catch (e) {
+ console.warn('[Notify] ntfy admin send failed:', e.message);
+ }
+}
+
+// ── Notification helpers for specific events ──
+
+// New login from unknown device
+function notifyNewLogin(userId, deviceLabel, ip) {
+ notifyUser(userId, 'New Login Detected',
+ 'New sign-in from ' + deviceLabel + ' (' + ip + '). If this was not you, change your password immediately.',
+ { priority: '4', tags: 'warning', click: '/settings' }
+ );
+}
+
+// Password changed
+function notifyPasswordChanged(userId) {
+ notifyUser(userId, 'Password Changed',
+ 'Your password was changed. All other sessions have been logged out.',
+ { priority: '4', tags: 'lock' }
+ );
+}
+
+// Encounter expiring soon (called from cleanup job)
+function notifyEncounterExpiring(userId, label, hoursLeft) {
+ notifyUser(userId, 'Encounter Expiring',
+ 'Your encounter "' + (label || 'Unlabeled') + '" will be deleted in ' + hoursLeft + ' hours. Save or export it now.',
+ { priority: '3', tags: 'clock' }
+ );
+}
+
+// New user registered (admin notification)
+function notifyNewRegistration(email, name) {
+ notifyAdmin('New User Registered',
+ name + ' (' + email + ') has registered.',
+ { priority: '3', tags: 'new' }
+ );
+}
+
+// Failed login attempts (admin notification)
+function notifyFailedLogins(email, count, ip) {
+ notifyAdmin('Failed Login Attempts',
+ count + ' failed login attempts for ' + email + ' from ' + ip,
+ { priority: '4', tags: 'warning' }
+ );
+}
+
+module.exports = {
+ notifyUser,
+ notifyAdmin,
+ notifyNewLogin,
+ notifyPasswordChanged,
+ notifyEncounterExpiring,
+ notifyNewRegistration,
+ notifyFailedLogins
+};