fix(admin): admin/profile routes and services

This commit is contained in:
null 2026-06-07 21:18:02 -05:00
parent 79b51b1c9a
commit 426b0fd932
7 changed files with 75 additions and 33 deletions

View File

@ -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();

View File

@ -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() {
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8 space-y-6">
<EmailNotifCard />
<BankSyncAdminCard />
<PrivacyAdminCard />
<BackupManagementCard />
<CleanupPanel />
<LoginModeCard users={users} onModeChange={setAuthMode} />

View File

@ -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 (
<SectionCard title="Privacy" icon={Lock} subtitle="Control what login data is collected about you.">
<div className="px-6 py-5 space-y-4">
<CheckRow
id="geo-enabled"
label="Login geolocation"
checked={geoEnabled}
onChange={setGeoEnabled}
/>
<p className="text-xs text-muted-foreground">
When on, your login IP is resolved to a city/region via{' '}
<span className="font-mono text-[11px]">ip-api.com</span> over plain HTTP.
Location data is encrypted at rest and visible only to you. Turn off to keep
all login data on-device.
</p>
</div>
<div className="px-6 py-4 border-t border-border/50 flex justify-end">
<Button onClick={save} disabled={saving || !changed}>
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Save Privacy Settings'}
</Button>
</div>
</SectionCard>
);
}
function ProfileNav() {
const items = [
['#account', 'Account'],
['#security', 'Security'],
['#notifications', 'Notifications'],
['#privacy', 'Privacy'],
];
return (
<div className="mb-6 flex flex-wrap gap-2">
@ -925,6 +972,9 @@ export default function ProfilePage() {
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
{!loading && <PushNotifications settings={settings} onSaved={() => api.profileSettings().then(setSettings).catch(() => {})} />}
</div>
<div id="privacy" className="scroll-mt-6">
{!loading && <PrivacySettings settings={settings} onSaved={setSettings} />}
</div>
</div>
</div>
);

View File

@ -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 ───────────────────────────────────────────

View File

@ -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;

View File

@ -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,
);

View File

@ -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) })