pediatric-ai-scribe-v3/mobile/android/app/src/main/assets/public/launcher.js
Daniel d11c38d404 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)
2026-04-11 02:35:07 +02:00

203 lines
6.4 KiB
JavaScript

// PedScribe Mobile Launcher
// 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');
var savedUrl = localStorage.getItem(STORAGE_KEY);
var biometricEnabled = localStorage.getItem(BIOMETRIC_KEY) === 'true';
// ── Launch flow ──
if (savedUrl) {
if (biometricEnabled && isCapacitor()) {
showBiometricScreen(savedUrl);
} else {
showConnecting(savedUrl);
}
} else {
urlInput.value = DEFAULT_URL;
showScreen('setup');
}
// ── 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...';
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;
connectBtn.textContent = 'Connect';
showError('Could not reach server. Check the URL and try again.');
}
});
});
urlInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') connectBtn.click();
});
// ── Change server ──
changeBtn.addEventListener('click', function() {
localStorage.removeItem(STORAGE_KEY);
urlInput.value = savedUrl || DEFAULT_URL;
showScreen('setup');
urlInput.focus();
});
// ── 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 {
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) {
if (done) return;
done = true;
callback(ok);
}
fetch(url + '/api/health', { mode: 'no-cors', signal: AbortSignal.timeout(8000) })
.then(function() { respond(true); })
.catch(function() {
var fallbackTimer = setTimeout(function() { respond(false); }, 5000);
var img = new Image();
img.onload = function() { clearTimeout(fallbackTimer); respond(true); };
img.onerror = function() { clearTimeout(fallbackTimer); respond(false); };
img.src = url + '/favicon.ico?t=' + Date.now();
});
}
function navigateToServer(url) {
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();
var div = document.createElement('div');
div.className = 'error-msg';
div.style.display = 'block';
div.textContent = msg;
var parent = connectBtn ? connectBtn.parentNode : document.querySelector('.launcher');
if (parent) parent.appendChild(div);
setTimeout(function() { if (div.parentNode) div.remove(); }, 8000);
}
})();