Add biometric auth, ntfy push notifications, mobile improvements

Mobile:
- Add biometric authentication (Face ID/Touch ID/fingerprint) on app launch
  with PIN/password fallback, auto-prompts on launch, skip option
- Add @aparajita/capacitor-biometric-auth plugin

Backend:
- Add ntfy push notification support (src/utils/notify.js)
  Self-hosted, no Firebase dependency, uses user's existing ntfy instance
- Notifications for: new login, password changed, new registration (admin)
- Topic format: pedscribe-user-{id} for users, pedscribe-admin for admins
- Env: NTFY_URL, NTFY_TOKEN (optional)
This commit is contained in:
Daniel 2026-04-11 02:35:07 +02:00
parent aa33a55d0b
commit d0d65446f6
17 changed files with 540 additions and 72 deletions

View file

@ -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

View file

@ -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')

View file

@ -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"

View file

@ -23,6 +23,23 @@
<button id="btn-change-server" class="btn-link">Change Server</button>
</div>
<!-- Biometric lock screen -->
<div id="biometric-screen" style="display:none;">
<div class="logo-icon">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="22" fill="white" fill-opacity="0.15"/>
<path d="M24 12c-2.2 0-4 1.8-4 4v8c0 2.2 1.8 4 4 4s4-1.8 4-4V16c0-2.2-1.8-4-4-4z" fill="white"/>
<path d="M32 22v2c0 4.4-3.6 8-8 8s-8-3.6-8-8v-2h-2v2c0 5.1 3.8 9.3 8.7 9.9V36H20v2h8v-2h-2.7v-2.1c4.9-.6 8.7-4.8 8.7-9.9v-2h-2z" fill="white"/>
</svg>
</div>
<h1>PedScribe</h1>
<p class="subtitle">Unlock to continue</p>
<button id="btn-unlock" class="btn-primary" style="margin-top:20px;">
Unlock with Biometrics
</button>
<button id="btn-skip-biometric" class="btn-link">Skip</button>
</div>
<!-- Server URL setup screen -->
<div id="setup-screen">
<div class="logo-icon">

View file

@ -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' });
}
}
})();

View file

@ -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')

View file

@ -36,6 +36,7 @@
},
"packageClassList": [
"AppPlugin",
"BiometricAuthNative",
"HapticsPlugin",
"KeyboardPlugin",
"PushNotificationsPlugin",

View file

@ -23,6 +23,23 @@
<button id="btn-change-server" class="btn-link">Change Server</button>
</div>
<!-- Biometric lock screen -->
<div id="biometric-screen" style="display:none;">
<div class="logo-icon">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="22" fill="white" fill-opacity="0.15"/>
<path d="M24 12c-2.2 0-4 1.8-4 4v8c0 2.2 1.8 4 4 4s4-1.8 4-4V16c0-2.2-1.8-4-4-4z" fill="white"/>
<path d="M32 22v2c0 4.4-3.6 8-8 8s-8-3.6-8-8v-2h-2v2c0 5.1 3.8 9.3 8.7 9.9V36H20v2h8v-2h-2.7v-2.1c4.9-.6 8.7-4.8 8.7-9.9v-2h-2z" fill="white"/>
</svg>
</div>
<h1>PedScribe</h1>
<p class="subtitle">Unlock to continue</p>
<button id="btn-unlock" class="btn-primary" style="margin-top:20px;">
Unlock with Biometrics
</button>
<button id="btn-skip-biometric" class="btn-link">Skip</button>
</div>
<!-- Server URL setup screen -->
<div id="setup-screen">
<div class="logo-icon">

View file

@ -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' });
}
}
})();

View file

@ -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'

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -23,6 +23,23 @@
<button id="btn-change-server" class="btn-link">Change Server</button>
</div>
<!-- Biometric lock screen -->
<div id="biometric-screen" style="display:none;">
<div class="logo-icon">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="22" fill="white" fill-opacity="0.15"/>
<path d="M24 12c-2.2 0-4 1.8-4 4v8c0 2.2 1.8 4 4 4s4-1.8 4-4V16c0-2.2-1.8-4-4-4z" fill="white"/>
<path d="M32 22v2c0 4.4-3.6 8-8 8s-8-3.6-8-8v-2h-2v2c0 5.1 3.8 9.3 8.7 9.9V36H20v2h8v-2h-2.7v-2.1c4.9-.6 8.7-4.8 8.7-9.9v-2h-2z" fill="white"/>
</svg>
</div>
<h1>PedScribe</h1>
<p class="subtitle">Unlock to continue</p>
<button id="btn-unlock" class="btn-primary" style="margin-top:20px;">
Unlock with Biometrics
</button>
<button id="btn-skip-biometric" class="btn-link">Skip</button>
</div>
<!-- Server URL setup screen -->
<div id="setup-screen">
<div class="logo-icon">

View file

@ -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' });
}
}
})();

View file

@ -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.';

110
src/utils/notify.js Normal file
View file

@ -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
};