fix(admin): admin/profile routes and services
This commit is contained in:
parent
79b51b1c9a
commit
426b0fd932
|
|
@ -412,10 +412,6 @@ export const api = {
|
||||||
bankSyncConfig: () => get('/admin/bank-sync-config'),
|
bankSyncConfig: () => get('/admin/bank-sync-config'),
|
||||||
setBankSyncConfig: (data) => put('/admin/bank-sync-config', data),
|
setBankSyncConfig: (data) => put('/admin/bank-sync-config', data),
|
||||||
|
|
||||||
// Admin — privacy settings
|
|
||||||
privacySettings: () => get('/admin/privacy'),
|
|
||||||
setPrivacySettings: (data) => put('/admin/privacy', data),
|
|
||||||
|
|
||||||
// User SQLite import
|
// User SQLite import
|
||||||
previewUserDbImport: async (file) => {
|
previewUserDbImport: async (file) => {
|
||||||
const csrfToken = await getCsrfToken();
|
const csrfToken = await getCsrfToken();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import UsersTable from '@/components/admin/UsersTable';
|
||||||
import AddUserCard from '@/components/admin/AddUserCard';
|
import AddUserCard from '@/components/admin/AddUserCard';
|
||||||
import BackupManagementCard from '@/components/admin/BackupManagementCard';
|
import BackupManagementCard from '@/components/admin/BackupManagementCard';
|
||||||
import CleanupPanel from '@/components/admin/CleanupPanel';
|
import CleanupPanel from '@/components/admin/CleanupPanel';
|
||||||
import PrivacyAdminCard from '@/components/admin/PrivacyAdminCard';
|
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const navigate = useNavigate();
|
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">
|
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8 space-y-6">
|
||||||
<EmailNotifCard />
|
<EmailNotifCard />
|
||||||
<BankSyncAdminCard />
|
<BankSyncAdminCard />
|
||||||
<PrivacyAdminCard />
|
|
||||||
<BackupManagementCard />
|
<BackupManagementCard />
|
||||||
<CleanupPanel />
|
<CleanupPanel />
|
||||||
<LoginModeCard users={users} onModeChange={setAuthMode} />
|
<LoginModeCard users={users} onModeChange={setAuthMode} />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight,
|
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';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
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() {
|
function ProfileNav() {
|
||||||
const items = [
|
const items = [
|
||||||
['#account', 'Account'],
|
['#account', 'Account'],
|
||||||
['#security', 'Security'],
|
['#security', 'Security'],
|
||||||
['#notifications', 'Notifications'],
|
['#notifications', 'Notifications'],
|
||||||
|
['#privacy', 'Privacy'],
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex flex-wrap gap-2">
|
<div className="mb-6 flex flex-wrap gap-2">
|
||||||
|
|
@ -925,6 +972,9 @@ export default function ProfilePage() {
|
||||||
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
|
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
|
||||||
{!loading && <PushNotifications settings={settings} onSaved={() => api.profileSettings().then(setSettings).catch(() => {})} />}
|
{!loading && <PushNotifications settings={settings} onSaved={() => api.profileSettings().then(setSettings).catch(() => {})} />}
|
||||||
</div>
|
</div>
|
||||||
|
<div id="privacy" className="scroll-mt-6">
|
||||||
|
{!loading && <PrivacySettings settings={settings} onSaved={setSettings} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3359,6 +3359,17 @@ function runMigrations() {
|
||||||
console.log('[v1.01] transactions.pending flag + partial index added');
|
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 ───────────────────────────────────────────
|
// ── users: notification columns ───────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
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 { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService');
|
||||||
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
|
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
|
||||||
const { isEnvKeyActive } = require('../services/encryptionService');
|
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 ────────────────────────────────────────────────────────
|
// ── Migration Rollback ────────────────────────────────────────────────────────
|
||||||
router.post('/migrations/rollback', async (req, res) => {
|
router.post('/migrations/rollback', async (req, res) => {
|
||||||
const { version } = req.body;
|
const { version } = req.body;
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,8 @@ router.get('/settings', (req, res) => {
|
||||||
const user = db.prepare(`
|
const user = db.prepare(`
|
||||||
SELECT notification_email, notifications_enabled,
|
SELECT notification_email, notifications_enabled,
|
||||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
|
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 = ?
|
FROM users WHERE id = ?
|
||||||
`).get(req.user.id);
|
`).get(req.user.id);
|
||||||
|
|
||||||
|
|
@ -169,6 +170,7 @@ router.get('/settings', (req, res) => {
|
||||||
push_url: pushUrlDecrypted || null,
|
push_url: pushUrlDecrypted || null,
|
||||||
push_token_set: !!user.push_token,
|
push_token_set: !!user.push_token,
|
||||||
push_chat_id: user.push_chat_id || null,
|
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,
|
notification_email, email, notifications_enabled,
|
||||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
|
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,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const nextEmail = notification_email !== undefined ? notification_email : email;
|
const nextEmail = notification_email !== undefined ? notification_email : email;
|
||||||
|
|
@ -203,7 +206,8 @@ router.patch('/settings', (req, res) => {
|
||||||
const current = db.prepare(`
|
const current = db.prepare(`
|
||||||
SELECT notification_email, notifications_enabled,
|
SELECT notification_email, notifications_enabled,
|
||||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
|
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 = ?
|
FROM users WHERE id = ?
|
||||||
`).get(req.user.id);
|
`).get(req.user.id);
|
||||||
|
|
||||||
|
|
@ -235,6 +239,7 @@ router.patch('/settings', (req, res) => {
|
||||||
push_url = ?,
|
push_url = ?,
|
||||||
push_token = ?,
|
push_token = ?,
|
||||||
push_chat_id = ?,
|
push_chat_id = ?,
|
||||||
|
geolocation_enabled = ?,
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
|
|
@ -250,6 +255,7 @@ router.patch('/settings', (req, res) => {
|
||||||
encryptOrNull(push_url, current.push_url),
|
encryptOrNull(push_url, current.push_url),
|
||||||
encryptOrNull(push_token, current.push_token),
|
encryptOrNull(push_token, current.push_token),
|
||||||
push_chat_id !== undefined ? (push_chat_id?.trim() || null) : current.push_chat_id,
|
push_chat_id !== undefined ? (push_chat_id?.trim() || null) : current.push_chat_id,
|
||||||
|
boolVal(geolocation_enabled, current.geolocation_enabled),
|
||||||
req.user.id,
|
req.user.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const { getDb, getSetting } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { buildDeviceFingerprint } = require('./loginFingerprint');
|
const { buildDeviceFingerprint } = require('./loginFingerprint');
|
||||||
const { encryptSecret, decryptSecret } = require('./encryptionService');
|
const { encryptSecret, decryptSecret } = require('./encryptionService');
|
||||||
|
|
||||||
|
|
@ -262,8 +262,9 @@ function recordLogin(userId, ipAddress, userAgent, sessionId) {
|
||||||
// Background tasks: geolocation + new device push alert
|
// Background tasks: geolocation + new device push alert
|
||||||
if (insertedId) {
|
if (insertedId) {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
// Geolocation — only when admin has opted in, and skip private/loopback IPs
|
// Geolocation — only when the user has opted in, and skip private/loopback IPs
|
||||||
if (ipAddress && getSetting('geolocation_enabled') === 'true') {
|
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);
|
const isPrivate = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1|localhost)/i.test(ipAddress);
|
||||||
if (!isPrivate) {
|
if (!isPrivate) {
|
||||||
fetch(`http://ip-api.com/json/${ipAddress}?fields=status,city,country,regionName,isp`, { signal: AbortSignal.timeout(5000) })
|
fetch(`http://ip-api.com/json/${ipAddress}?fields=status,city,country,regionName,isp`, { signal: AbortSignal.timeout(5000) })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue