diff --git a/mobile/package.json b/mobile/package.json index 163e7c5..40cd5dd 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -23,6 +23,7 @@ "@capacitor/screen-orientation": "^6.0.0", "@capacitor/share": "^6.0.0", "@capacitor/splash-screen": "^6.0.0", - "@capacitor/status-bar": "^6.0.0" + "@capacitor/status-bar": "^6.0.0", + "capacitor-secure-storage-plugin": "^0.10.0" } } diff --git a/public/index.html b/public/index.html index 8f9616b..969d628 100644 --- a/public/index.html +++ b/public/index.html @@ -422,6 +422,7 @@ + diff --git a/public/js/auth.js b/public/js/auth.js index 8489882..bc8a03f 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -33,7 +33,10 @@ document.addEventListener('DOMContentLoaded', function() { if (ssoOk === 'ok') { var ssoSid = urlParams.get('sid'); history.replaceState(null, '', window.location.pathname); - if (ssoSid) localStorage.setItem(SESSION_KEY, ssoSid); + if (ssoSid) { + if (window.SecureStorage) window.SecureStorage.set(SESSION_KEY, ssoSid); + else localStorage.setItem(SESSION_KEY, ssoSid); + } // Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically) fetch('/api/auth/me', { credentials: 'same-origin' }) .then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); }) @@ -72,36 +75,43 @@ document.addEventListener('DOMContentLoaded', function() { }) .catch(function() {}); - var savedToken = localStorage.getItem(TOKEN_KEY); - if (ssoOk) { - // SSO handled above — do nothing here - } else if (ssoError) { - // SSO error handled above — auth screen already shown - } else if (savedToken) { - // Existing token — verify silently; auth screen stays hidden during check - fetch('/api/auth/me', { - headers: { 'Authorization': 'Bearer ' + savedToken } - }) - .then(function(r) { - if (r.ok) return r.json(); - throw new Error('expired'); - }) - .then(function(data) { - if (data && data.user) { - enterApp(data.user, savedToken); - } else { + var hydrate = window.SecureStorage + ? window.SecureStorage.hydrate([TOKEN_KEY, USER_KEY, SESSION_KEY]) + : Promise.resolve(); + + hydrate.then(function() { + var savedToken = window.SecureStorage + ? window.SecureStorage.getSync(TOKEN_KEY) + : localStorage.getItem(TOKEN_KEY); + + if (ssoOk) { + // SSO handled above — do nothing here + } else if (ssoError) { + // SSO error handled above — auth screen already shown + } else if (savedToken) { + fetch('/api/auth/me', { + headers: { 'Authorization': 'Bearer ' + savedToken } + }) + .then(function(r) { + if (r.ok) return r.json(); + throw new Error('expired'); + }) + .then(function(data) { + if (data && data.user) { + enterApp(data.user, savedToken); + } else { + showAuthScreen(); + clearSession(); + } + }) + .catch(function() { showAuthScreen(); clearSession(); - } - }) - .catch(function() { + }); + } else { showAuthScreen(); - clearSession(); - }); - } else { - // No token — show login immediately - showAuthScreen(); - } + } + }); // ---- HELPER FUNCTIONS ---- @@ -125,8 +135,13 @@ document.addEventListener('DOMContentLoaded', function() { function enterApp(user, token) { window.AUTH_TOKEN = token; - localStorage.setItem(TOKEN_KEY, token); - localStorage.setItem(USER_KEY, JSON.stringify(user)); + if (window.SecureStorage) { + window.SecureStorage.set(TOKEN_KEY, token); + window.SecureStorage.set(USER_KEY, JSON.stringify(user)); + } else { + localStorage.setItem(TOKEN_KEY, token); + localStorage.setItem(USER_KEY, JSON.stringify(user)); + } if (authScreen) authScreen.style.display = 'none'; if (mainApp) { @@ -191,16 +206,25 @@ document.addEventListener('DOMContentLoaded', function() { } function clearSession() { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(USER_KEY); - localStorage.removeItem(SESSION_KEY); + if (window.SecureStorage) { + window.SecureStorage.remove(TOKEN_KEY); + window.SecureStorage.remove(USER_KEY); + window.SecureStorage.remove(SESSION_KEY); + } else { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + localStorage.removeItem(SESSION_KEY); + } window.AUTH_TOKEN = null; } window.getAuthHeaders = function() { + var token = window.AUTH_TOKEN; + if (!token && window.SecureStorage) token = window.SecureStorage.getSync(TOKEN_KEY); + if (!token) { try { token = localStorage.getItem(TOKEN_KEY); } catch(e) {} } return { 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem(TOKEN_KEY) || '') + 'Authorization': 'Bearer ' + (token || '') }; }; @@ -383,7 +407,10 @@ document.addEventListener('DOMContentLoaded', function() { } if (data.success && data.token && data.user) { - if (data.sessionId) localStorage.setItem(SESSION_KEY, data.sessionId); + if (data.sessionId) { + if (window.SecureStorage) window.SecureStorage.set(SESSION_KEY, data.sessionId); + else localStorage.setItem(SESSION_KEY, data.sessionId); + } enterApp(data.user, data.token); showToast('Welcome, ' + data.user.name + '!', 'success'); } else { @@ -473,7 +500,10 @@ document.addEventListener('DOMContentLoaded', function() { hideLoading(); if (data.success && data.token && data.user) { - if (data.sessionId) localStorage.setItem(SESSION_KEY, data.sessionId); + if (data.sessionId) { + if (window.SecureStorage) window.SecureStorage.set(SESSION_KEY, data.sessionId); + else localStorage.setItem(SESSION_KEY, data.sessionId); + } enterApp(data.user, data.token); showToast(data.message || 'Account created!', 'success'); } else if (data.success && data.needsVerification) { @@ -555,7 +585,7 @@ document.addEventListener('DOMContentLoaded', function() { list.innerHTML = '

No active sessions found.

'; return; } - var currentSid = data.currentSessionId || localStorage.getItem(SESSION_KEY); + var currentSid = data.currentSessionId || (window.SecureStorage ? window.SecureStorage.getSync(SESSION_KEY) : localStorage.getItem(SESSION_KEY)); list.innerHTML = data.sessions.map(function(s) { var isCurrent = s.id === currentSid; var created = new Date(s.created_at).toLocaleDateString(); diff --git a/public/js/documents.js b/public/js/documents.js index b46ca2b..480e02e 100644 --- a/public/js/documents.js +++ b/public/js/documents.js @@ -64,7 +64,7 @@ showLoading('Uploading document...'); fetch('/api/documents/upload', { method: 'POST', - headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') }, + headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || (window.SecureStorage && window.SecureStorage.getSync('ped_scribe_token')) || localStorage.getItem('ped_scribe_token') || '') }, body: formData }) .then(function(r) { return r.json(); }) diff --git a/public/js/secureStorage.js b/public/js/secureStorage.js new file mode 100644 index 0000000..d4eb503 --- /dev/null +++ b/public/js/secureStorage.js @@ -0,0 +1,73 @@ +// ============================================================ +// SECURE STORAGE WRAPPER +// Browser: localStorage +// Capacitor mobile: iOS Keychain / Android EncryptedSharedPreferences +// via capacitor-secure-storage-plugin +// ============================================================ +(function() { + var memCache = {}; + + function isNative() { + try { + return !!(window.Capacitor && typeof window.Capacitor.isNativePlatform === 'function' && window.Capacitor.isNativePlatform()); + } catch(e) { return false; } + } + + function plugin() { + try { + var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.SecureStoragePlugin; + return p || null; + } catch(e) { return null; } + } + + window.SecureStorage = { + isNative: isNative, + + get: function(key) { + if (isNative() && plugin()) { + return plugin().get({ key: key }) + .then(function(r) { memCache[key] = r.value; return r.value; }) + .catch(function() { return null; }); + } + try { return Promise.resolve(localStorage.getItem(key)); } catch(e) { return Promise.resolve(null); } + }, + + set: function(key, value) { + memCache[key] = value; + if (isNative() && plugin()) { + return plugin().set({ key: key, value: value }).catch(function() {}); + } + try { localStorage.setItem(key, value); } catch(e) {} + return Promise.resolve(); + }, + + remove: function(key) { + delete memCache[key]; + if (isNative() && plugin()) { + var p = plugin().remove({ key: key }).catch(function() {}); + // Also clear legacy localStorage copy if any + try { localStorage.removeItem(key); } catch(e) {} + return p; + } + try { localStorage.removeItem(key); } catch(e) {} + return Promise.resolve(); + }, + + // Synchronous read for hot paths (fetch headers). On native returns cached + // value populated by prior get/set. On web reads localStorage directly. + getSync: function(key) { + if (isNative()) return memCache[key] || null; + try { return localStorage.getItem(key); } catch(e) { return null; } + }, + + // Hydrate the memory cache from native storage at boot. Resolves when ready. + hydrate: function(keys) { + if (!isNative() || !plugin()) return Promise.resolve(); + return Promise.all(keys.map(function(k) { + return plugin().get({ key: k }) + .then(function(r) { memCache[k] = r.value; }) + .catch(function() { memCache[k] = null; }); + })); + } + }; +})();