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:
parent
aa33a55d0b
commit
d0d65446f6
17 changed files with 540 additions and 72 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
},
|
||||
"packageClassList": [
|
||||
"AppPlugin",
|
||||
"BiometricAuthNative",
|
||||
"HapticsPlugin",
|
||||
"KeyboardPlugin",
|
||||
"PushNotificationsPlugin",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
15
mobile/node_modules/.package-lock.json
generated
vendored
15
mobile/node_modules/.package-lock.json
generated
vendored
|
|
@ -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",
|
||||
|
|
|
|||
16
mobile/package-lock.json
generated
16
mobile/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
110
src/utils/notify.js
Normal 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
|
||||
};
|
||||
Loading…
Reference in a new issue