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)
203 lines
6.4 KiB
JavaScript
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);
|
|
}
|
|
})();
|