diff --git a/client/api.js b/client/api.js index 378405b..602c5c7 100644 --- a/client/api.js +++ b/client/api.js @@ -412,10 +412,6 @@ export const api = { bankSyncConfig: () => get('/admin/bank-sync-config'), setBankSyncConfig: (data) => put('/admin/bank-sync-config', data), - // Admin — privacy settings - privacySettings: () => get('/admin/privacy'), - setPrivacySettings: (data) => put('/admin/privacy', data), - // User SQLite import previewUserDbImport: async (file) => { const csrfToken = await getCsrfToken(); diff --git a/client/pages/AdminPage.jsx b/client/pages/AdminPage.jsx index ad45e1b..130cb17 100644 --- a/client/pages/AdminPage.jsx +++ b/client/pages/AdminPage.jsx @@ -11,7 +11,6 @@ import UsersTable from '@/components/admin/UsersTable'; import AddUserCard from '@/components/admin/AddUserCard'; import BackupManagementCard from '@/components/admin/BackupManagementCard'; import CleanupPanel from '@/components/admin/CleanupPanel'; -import PrivacyAdminCard from '@/components/admin/PrivacyAdminCard'; export default function AdminPage() { const navigate = useNavigate(); @@ -85,7 +84,6 @@ export default function AdminPage() {
- diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.jsx index 3275d3d..a806d4d 100644 --- a/client/pages/ProfilePage.jsx +++ b/client/pages/ProfilePage.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import { toast } from 'sonner'; import { User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight, - Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check, + Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check, Lock, } from 'lucide-react'; import { api } from '@/api'; import { useAuth } from '@/hooks/useAuth'; @@ -848,11 +848,58 @@ function TotpSection() { ); } +function PrivacySettings({ settings, onSaved }) { + const [geoEnabled, setGeoEnabled] = useState(!!settings.geolocation_enabled); + const [saving, setSaving] = useState(false); + + useEffect(() => { setGeoEnabled(!!settings.geolocation_enabled); }, [settings.geolocation_enabled]); + + const save = async () => { + setSaving(true); + try { + await api.updateProfileSettings({ geolocation_enabled: geoEnabled }); + toast.success('Privacy settings saved.'); + onSaved({ ...settings, geolocation_enabled: geoEnabled }); + } catch (err) { + toast.error(err.message || 'Failed to save privacy settings.'); + } finally { + setSaving(false); + } + }; + + const changed = geoEnabled !== !!settings.geolocation_enabled; + + return ( + +
+ +

+ When on, your login IP is resolved to a city/region via{' '} + ip-api.com over plain HTTP. + Location data is encrypted at rest and visible only to you. Turn off to keep + all login data on-device. +

+
+
+ +
+
+ ); +} + function ProfileNav() { const items = [ ['#account', 'Account'], ['#security', 'Security'], ['#notifications', 'Notifications'], + ['#privacy', 'Privacy'], ]; return (
@@ -925,6 +972,9 @@ export default function ProfilePage() { {!loading && } {!loading && api.profileSettings().then(setSettings).catch(() => {})} />}
+
+ {!loading && } +
); diff --git a/db/database.js b/db/database.js index 3b575b3..233f427 100644 --- a/db/database.js +++ b/db/database.js @@ -3359,6 +3359,17 @@ function runMigrations() { console.log('[v1.01] transactions.pending flag + partial index added'); } }, + { + version: 'v1.02', + description: 'users: per-user geolocation opt-in (was global admin setting)', + run() { + const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!cols.includes('geolocation_enabled')) { + db.exec('ALTER TABLE users ADD COLUMN geolocation_enabled INTEGER NOT NULL DEFAULT 0'); + } + console.log('[v1.02] users.geolocation_enabled added'); + } + }, ]; // ── users: notification columns ─────────────────────────────────────────── diff --git a/routes/admin.js b/routes/admin.js index 71fd12c..70e3a1f 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -1,6 +1,6 @@ const express = require('express'); const router = express.Router(); -const { getDb, getSetting, setSetting, rollbackMigration } = require('../db/database'); +const { getDb, rollbackMigration } = require('../db/database'); const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { isEnvKeyActive } = require('../services/encryptionService'); @@ -458,26 +458,6 @@ router.put('/bank-sync-config', (req, res) => { } }); -// ── Privacy Settings ────────────────────────────────────────────────────────── - -// GET /api/admin/privacy -router.get('/privacy', (req, res) => { - res.json({ - geolocation_enabled: getSetting('geolocation_enabled') === 'true', - }); -}); - -// PUT /api/admin/privacy -router.put('/privacy', (req, res) => { - const { geolocation_enabled } = req.body || {}; - if (typeof geolocation_enabled === 'boolean') { - setSetting('geolocation_enabled', geolocation_enabled ? 'true' : 'false'); - } - res.json({ - geolocation_enabled: getSetting('geolocation_enabled') === 'true', - }); -}); - // ── Migration Rollback ──────────────────────────────────────────────────────── router.post('/migrations/rollback', async (req, res) => { const { version } = req.body; diff --git a/routes/profile.js b/routes/profile.js index 677b8e7..2978ee2 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -147,7 +147,8 @@ router.get('/settings', (req, res) => { const user = db.prepare(` SELECT notification_email, notifications_enabled, notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change, - notify_push_enabled, push_channel, push_url, push_token, push_chat_id + notify_push_enabled, push_channel, push_url, push_token, push_chat_id, + geolocation_enabled FROM users WHERE id = ? `).get(req.user.id); @@ -169,6 +170,7 @@ router.get('/settings', (req, res) => { push_url: pushUrlDecrypted || null, push_token_set: !!user.push_token, push_chat_id: user.push_chat_id || null, + geolocation_enabled: !!user.geolocation_enabled, }); }); @@ -183,6 +185,7 @@ router.patch('/settings', (req, res) => { notification_email, email, notifications_enabled, notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change, notify_push_enabled, push_channel, push_url, push_token, push_chat_id, + geolocation_enabled, } = req.body; const nextEmail = notification_email !== undefined ? notification_email : email; @@ -203,7 +206,8 @@ router.patch('/settings', (req, res) => { const current = db.prepare(` SELECT notification_email, notifications_enabled, notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change, - notify_push_enabled, push_channel, push_url, push_token, push_chat_id + notify_push_enabled, push_channel, push_url, push_token, push_chat_id, + geolocation_enabled FROM users WHERE id = ? `).get(req.user.id); @@ -235,6 +239,7 @@ router.patch('/settings', (req, res) => { push_url = ?, push_token = ?, push_chat_id = ?, + geolocation_enabled = ?, updated_at = datetime('now') WHERE id = ? `).run( @@ -250,6 +255,7 @@ router.patch('/settings', (req, res) => { encryptOrNull(push_url, current.push_url), encryptOrNull(push_token, current.push_token), push_chat_id !== undefined ? (push_chat_id?.trim() || null) : current.push_chat_id, + boolVal(geolocation_enabled, current.geolocation_enabled), req.user.id, ); diff --git a/services/authService.js b/services/authService.js index b9cb402..efc6db4 100644 --- a/services/authService.js +++ b/services/authService.js @@ -1,6 +1,6 @@ const crypto = require('crypto'); const bcrypt = require('bcryptjs'); -const { getDb, getSetting } = require('../db/database'); +const { getDb } = require('../db/database'); const { buildDeviceFingerprint } = require('./loginFingerprint'); const { encryptSecret, decryptSecret } = require('./encryptionService'); @@ -262,8 +262,9 @@ function recordLogin(userId, ipAddress, userAgent, sessionId) { // Background tasks: geolocation + new device push alert if (insertedId) { setImmediate(() => { - // Geolocation — only when admin has opted in, and skip private/loopback IPs - if (ipAddress && getSetting('geolocation_enabled') === 'true') { + // Geolocation — only when the user has opted in, and skip private/loopback IPs + const loginUser = getDb().prepare('SELECT geolocation_enabled FROM users WHERE id = ?').get(userId); + if (ipAddress && loginUser?.geolocation_enabled) { const isPrivate = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1|localhost)/i.test(ipAddress); if (!isPrivate) { fetch(`http://ip-api.com/json/${ipAddress}?fields=status,city,country,regionName,isp`, { signal: AbortSignal.timeout(5000) })