diff --git a/.env.example b/.env.example index 0319e80..57bd3de 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,10 @@ OPENAI_API_KEY=sk-your-openai-key # Optional ELEVENLABS_API_KEY= +# Push Notifications (ntfy — self-hosted, optional) +# NTFY_URL=https://ntfy.yourdomain.com +# NTFY_TOKEN=tk_your_token_here + # App PORT=3000 APP_URL=https://your-domain.com diff --git a/mobile/android/app/capacitor.build.gradle b/mobile/android/app/capacitor.build.gradle index f41a12d..d490550 100644 --- a/mobile/android/app/capacitor.build.gradle +++ b/mobile/android/app/capacitor.build.gradle @@ -10,6 +10,7 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-app') + implementation project(':aparajita-capacitor-biometric-auth') implementation project(':capacitor-haptics') implementation project(':capacitor-keyboard') implementation project(':capacitor-push-notifications') diff --git a/mobile/android/app/src/main/assets/capacitor.plugins.json b/mobile/android/app/src/main/assets/capacitor.plugins.json index e2b857c..493a356 100644 --- a/mobile/android/app/src/main/assets/capacitor.plugins.json +++ b/mobile/android/app/src/main/assets/capacitor.plugins.json @@ -3,6 +3,10 @@ "pkg": "@capacitor/app", "classpath": "com.capacitorjs.plugins.app.AppPlugin" }, + { + "pkg": "@aparajita/capacitor-biometric-auth", + "classpath": "com.aparajita.capacitor.biometricauth.BiometricAuthNative" + }, { "pkg": "@capacitor/haptics", "classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin" diff --git a/mobile/android/app/src/main/assets/public/index.html b/mobile/android/app/src/main/assets/public/index.html index 3d285dd..2936fd2 100644 --- a/mobile/android/app/src/main/assets/public/index.html +++ b/mobile/android/app/src/main/assets/public/index.html @@ -23,6 +23,23 @@ + + +
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 @@
+ + +
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 +};