diff --git a/HISTORY.md b/HISTORY.md
index 67e1cc5..6a97745 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -8,6 +8,10 @@
- **Login history: encrypted at rest + geolocation + new device alerts + failed attempt tracking + session detection + 10 records** — `user_login_history` now stores `ip_address` and `user_agent` encrypted with AES-256-GCM (same `encryptionService` used for SMTP, OIDC, and bank tokens). Migration v0.84 retroactively encrypts any existing plaintext rows and adds four new location columns (`location_city`, `location_country`, `location_region`, `location_isp`). On each new login, a non-blocking fire-and-forget request to `ip-api.com` resolves the IP to a city/region/country/ISP and stores it encrypted. Private and loopback IPs are skipped for geolocation. The login-history API decrypts all fields server-side before returning them — only the authenticated user can see their own data. History limit increased from 3 to 10 records. The Profile page now shows city, region, country, and ISP below each login entry. Migration v0.85 adds `success` (1 = successful login, 0 = failed attempt) and `session_fingerprint` (SHA-256 of the session ID) columns. Failed login attempts with wrong passwords are now recorded and displayed in the history modal with a red "Failed attempt" badge. The current active session is marked with a "This session" badge by comparing the session cookie fingerprint against stored values. New device logins (device fingerprint not seen in previous 10 logins) trigger a push notification via the user's configured channel (ntfy, Gotify, Discord, or Telegram). The summary card on the Profile page now shows location and always reflects the most recent successful login, not a failed attempt.
+- **Login history for single-user mode** — In single-user mode the app bypasses the session system entirely, so `recordLogin` was never called and login history was always empty. Fixed by issuing a `bt_single_session` presence cookie (httpOnly, 30-day, same security flags as the regular session cookie) on the first request from each new browser. `recordLogin` fires once per new cookie via `setImmediate` so it never delays page load. Return visits reuse the cookie and don't create duplicate entries. The login-history route now uses `bt_single_session` when `req.singleUserMode` is set so the "This session" fingerprint comparison works correctly without a real session cookie. OIDC logins were also missing the `sessionId` parameter passed to `recordLogin`, so "This session" never matched for OIDC sessions; fixed by passing `session.sessionId` through the OIDC callback.
+
+- **No Login mode — admin UI redesign** — The `LoginModeCard` in the Admin page is now a clean radio-group selector with two first-class options: **Require Login** (multi-user, shows the OIDC/local-login settings card below) and **No Login — Single User** (hides the auth methods card, shows a user picker and an amber security warning). An inline confirmation dialog is shown before enabling No Login mode. The `AuthMethodsCard` is conditionally hidden when single-user mode is active since OIDC and local-login settings are irrelevant when there is no login screen. The backend lockout validation (which previously blocked saving when all login methods were disabled) now skips entirely when `auth_mode === 'single'`. The `LoginPage` no longer flashes the sign-in form in single-user mode — it renders a neutral loading state while the auth-mode check is in-flight and redirects before the form ever appears.
+
- **`payments.js` SQL fragment renamed for clarity** — `const LIVE = 'deleted_at IS NULL'` was renamed to `const SQL_NOT_DELETED` and given a 4-line comment explaining why SQL fragment interpolation is safe here, why parameterisation is not applicable to SQL fragments (only values can be bound, not column conditions), and explicitly warning future developers not to replace the pattern with dynamic input.
- **Migration version sync assertion** — `_runMigrationVersions` module-level variable is now populated by `runMigrations()` before its loop runs. `reconcileLegacyMigrations()` — which runs after `runMigrations()` on legacy-DB upgrade paths — compares its own version array against the stored list and throws a descriptive error if any version appears in one array but not the other. Catches drift between the two migration arrays at startup rather than silently misconfiguring a legacy schema.
diff --git a/client/api.js b/client/api.js
index 12e759a..d07813c 100644
--- a/client/api.js
+++ b/client/api.js
@@ -64,6 +64,11 @@ export const api = {
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),
acknowledgeVersion: () => post('/auth/acknowledge-version'),
loginHistory: () => get('/auth/login-history'),
+ totpStatus: () => get('/auth/totp/status'),
+ totpSetup: () => get('/auth/totp/setup'),
+ totpEnable: (data) => post('/auth/totp/enable', data),
+ totpDisable: (data) => post('/auth/totp/disable', data),
+ totpChallenge: (data) => post('/auth/totp/challenge', data),
// Admin
hasUsers: () => get('/admin/has-users'),
diff --git a/client/pages/LoginPage.jsx b/client/pages/LoginPage.jsx
index 505a01e..3275e77 100644
--- a/client/pages/LoginPage.jsx
+++ b/client/pages/LoginPage.jsx
@@ -28,6 +28,13 @@ export default function LoginPage() {
const [showChangePw, setShowChangePw] = useState(false);
const [showPrivacy, setShowPrivacy] = useState(false);
+ // TOTP challenge state
+ const [totpChallenge, setTotpChallenge] = useState(null); // challenge_token string
+ const [totpCode, setTotpCode] = useState('');
+ const [totpError, setTotpError] = useState('');
+ const [totpLoading, setTotpLoading] = useState(false);
+ const [useRecovery, setUseRecovery] = useState(false);;
+
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false);
@@ -70,6 +77,13 @@ export default function LoginPage() {
setLoading(true);
try {
const data = await api.login({ username, password });
+ if (data.requires_totp) {
+ setTotpChallenge(data.challenge_token);
+ setTotpCode('');
+ setTotpError('');
+ setUseRecovery(false);
+ return;
+ }
handlePostLogin(data.user);
} catch (err) {
setError(err.message || 'Login failed.');
@@ -78,6 +92,24 @@ export default function LoginPage() {
}
};
+ const handleTotpSubmit = async (e) => {
+ e.preventDefault();
+ setTotpError('');
+ setTotpLoading(true);
+ try {
+ const payload = { challenge_token: totpChallenge };
+ if (useRecovery) payload.recovery_code = totpCode;
+ else payload.code = totpCode;
+ const data = await api.totpChallenge(payload);
+ handlePostLogin(data.user);
+ } catch (err) {
+ setTotpError(err.message || 'Invalid code.');
+ setTotpCode('');
+ } finally {
+ setTotpLoading(false);
+ }
+ };
+
const localEnabled = authMode?.local_enabled !== false;
const oidcEnabled = !!authMode?.oidc_enabled && !!authMode?.oidc_login_url;
const providerName = authMode?.oidc_provider_name || 'authentik';
@@ -139,8 +171,66 @@ export default function LoginPage() {
/>
- {/* Card — hidden while auth mode is still resolving to avoid flash in single-user mode */}
- {authMode === null ? (
+ {/* TOTP step — shown after password is accepted */}
+ {totpChallenge && (
+
+
+
Two-factor authentication
+
+ {useRecovery ? 'Enter one of your recovery codes.' : 'Enter the 6-digit code from your authenticator app.'}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Sign-in card — hidden while auth mode resolves or during TOTP step */}
+ {!totpChallenge && (authMode === null ? (
Loading…
@@ -243,7 +333,7 @@ export default function LoginPage() {
- )} {/* end authMode !== null */}
+ ))} {/* end !totpChallenge + authMode check */}
{/* Change Password Dialog */}
diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.jsx
index 87a9549..edfe554 100644
--- a/client/pages/ProfilePage.jsx
+++ b/client/pages/ProfilePage.jsx
@@ -1,8 +1,8 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useCallback } from 'react';
import { toast } from 'sonner';
import {
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight,
- Bell, SendHorizontal,
+ Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check,
} from 'lucide-react';
import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth';
@@ -637,6 +637,8 @@ function ChangePassword() {
};
return (
+ <>
+