@@ -42,6 +42,10 @@ export const authApi = {
|
||||
register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
||||
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data),
|
||||
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
|
||||
|
||||
@@ -173,6 +173,22 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.avatarUploaded': 'Profilbild aktualisiert',
|
||||
'settings.avatarRemoved': 'Profilbild entfernt',
|
||||
'settings.avatarError': 'Fehler beim Hochladen',
|
||||
'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)',
|
||||
'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).',
|
||||
'settings.mfa.enabled': '2FA ist für dein Konto aktiv.',
|
||||
'settings.mfa.disabled': '2FA ist nicht aktiviert.',
|
||||
'settings.mfa.setup': 'Authenticator einrichten',
|
||||
'settings.mfa.scanQr': 'QR-Code mit der App scannen oder den Schlüssel manuell eingeben.',
|
||||
'settings.mfa.secretLabel': 'Geheimer Schlüssel (manuell)',
|
||||
'settings.mfa.codePlaceholder': '6-stelliger Code',
|
||||
'settings.mfa.enable': '2FA aktivieren',
|
||||
'settings.mfa.cancelSetup': 'Abbrechen',
|
||||
'settings.mfa.disableTitle': '2FA deaktivieren',
|
||||
'settings.mfa.disableHint': 'Passwort und einen aktuellen Code aus der Authenticator-App eingeben.',
|
||||
'settings.mfa.disable': '2FA deaktivieren',
|
||||
'settings.mfa.toastEnabled': 'Zwei-Faktor-Authentifizierung aktiviert',
|
||||
'settings.mfa.toastDisabled': 'Zwei-Faktor-Authentifizierung deaktiviert',
|
||||
'settings.mfa.demoBlocked': 'In der Demo nicht verfügbar',
|
||||
|
||||
// Login
|
||||
'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.',
|
||||
@@ -217,6 +233,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.oidcSignIn': 'Anmelden mit {name}',
|
||||
'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.',
|
||||
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
|
||||
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
|
||||
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
|
||||
'login.mfaCodeLabel': 'Bestätigungscode',
|
||||
'login.mfaCodeRequired': 'Bitte den Code aus der Authenticator-App eingeben.',
|
||||
'login.mfaHint': 'Google Authenticator, Authy oder eine andere TOTP-App öffnen.',
|
||||
'login.mfaBack': '← Zurück zur Anmeldung',
|
||||
'login.mfaVerify': 'Bestätigen',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||
|
||||
@@ -173,6 +173,22 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.avatarUploaded': 'Profile picture updated',
|
||||
'settings.avatarRemoved': 'Profile picture removed',
|
||||
'settings.avatarError': 'Upload failed',
|
||||
'settings.mfa.title': 'Two-factor authentication (2FA)',
|
||||
'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).',
|
||||
'settings.mfa.enabled': '2FA is enabled on your account.',
|
||||
'settings.mfa.disabled': '2FA is not enabled.',
|
||||
'settings.mfa.setup': 'Set up authenticator',
|
||||
'settings.mfa.scanQr': 'Scan this QR code with your app, or enter the secret manually.',
|
||||
'settings.mfa.secretLabel': 'Secret key (manual entry)',
|
||||
'settings.mfa.codePlaceholder': '6-digit code',
|
||||
'settings.mfa.enable': 'Enable 2FA',
|
||||
'settings.mfa.cancelSetup': 'Cancel',
|
||||
'settings.mfa.disableTitle': 'Disable 2FA',
|
||||
'settings.mfa.disableHint': 'Enter your account password and a current code from your authenticator.',
|
||||
'settings.mfa.disable': 'Disable 2FA',
|
||||
'settings.mfa.toastEnabled': 'Two-factor authentication enabled',
|
||||
'settings.mfa.toastDisabled': 'Two-factor authentication disabled',
|
||||
'settings.mfa.demoBlocked': 'Not available in demo mode',
|
||||
|
||||
// Login
|
||||
'login.error': 'Login failed. Please check your credentials.',
|
||||
@@ -217,6 +233,13 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.oidcSignIn': 'Sign in with {name}',
|
||||
'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.',
|
||||
'login.demoHint': 'Try the demo — no registration needed',
|
||||
'login.mfaTitle': 'Two-factor authentication',
|
||||
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
|
||||
'login.mfaCodeLabel': 'Verification code',
|
||||
'login.mfaCodeRequired': 'Enter the code from your authenticator app.',
|
||||
'login.mfaHint': 'Open Google Authenticator, Authy, or another TOTP app.',
|
||||
'login.mfaBack': '← Back to sign in',
|
||||
'login.mfaVerify': 'Verify',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Passwords do not match',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield } from 'lucide-react'
|
||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react'
|
||||
|
||||
interface AppConfig {
|
||||
has_users: boolean
|
||||
@@ -28,7 +28,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
const [inviteToken, setInviteToken] = useState<string>('')
|
||||
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
||||
|
||||
const { login, register, demoLogin } = useAuthStore()
|
||||
const { login, register, demoLogin, completeMfaLogin } = useAuthStore()
|
||||
const { setLanguageLocal } = useSettingsStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -101,18 +101,39 @@ export default function LoginPage(): React.ReactElement {
|
||||
}
|
||||
|
||||
const [showTakeoff, setShowTakeoff] = useState<boolean>(false)
|
||||
const [mfaStep, setMfaStep] = useState(false)
|
||||
const [mfaToken, setMfaToken] = useState('')
|
||||
const [mfaCode, setMfaCode] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (mode === 'login' && mfaStep) {
|
||||
if (!mfaCode.trim()) {
|
||||
setError(t('login.mfaCodeRequired'))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
await completeMfaLogin(mfaToken, mfaCode)
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
return
|
||||
}
|
||||
if (mode === 'register') {
|
||||
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
|
||||
if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return }
|
||||
await register(username, email, password, inviteToken || undefined)
|
||||
} else {
|
||||
await login(email, password)
|
||||
const result = await login(email, password)
|
||||
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
|
||||
setMfaToken(result.mfa_token)
|
||||
setMfaStep(true)
|
||||
setMfaCode('')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
@@ -489,10 +510,18 @@ export default function LoginPage(): React.ReactElement {
|
||||
) : (
|
||||
<>
|
||||
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
|
||||
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) : t('login.title')}
|
||||
{mode === 'login' && mfaStep
|
||||
? t('login.mfaTitle')
|
||||
: mode === 'register'
|
||||
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
|
||||
: t('login.title')}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
|
||||
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) : t('login.subtitle')}
|
||||
{mode === 'login' && mfaStep
|
||||
? t('login.mfaSubtitle')
|
||||
: mode === 'register'
|
||||
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
|
||||
: t('login.subtitle')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
@@ -502,6 +531,35 @@ export default function LoginPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'login' && mfaStep && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={mfaCode}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder="000000"
|
||||
required
|
||||
style={inputBase}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', marginTop: 8 }}>{t('login.mfaHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMfaStep(false); setMfaToken(''); setMfaCode(''); setError('') }}
|
||||
style={{ marginTop: 8, background: 'none', border: 'none', color: '#6b7280', fontSize: 13, cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('login.mfaBack')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username (register only) */}
|
||||
{mode === 'register' && (
|
||||
<div>
|
||||
@@ -519,6 +577,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
{!(mode === 'login' && mfaStep) && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -531,8 +590,10 @@ export default function LoginPage(): React.ReactElement {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
{!(mode === 'login' && mfaStep) && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -551,6 +612,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={isLoading} style={{
|
||||
marginTop: 4, width: '100%', padding: '12px', background: '#111827', color: 'white',
|
||||
@@ -562,8 +624,8 @@ export default function LoginPage(): React.ReactElement {
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
|
||||
>
|
||||
{isLoading
|
||||
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
|
||||
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : t('login.signIn')}</>
|
||||
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
|
||||
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
@@ -572,7 +634,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (
|
||||
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
|
||||
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
|
||||
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError('') }}
|
||||
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }}
|
||||
style={{ background: 'none', border: 'none', color: '#111827', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13 }}>
|
||||
{mode === 'login' ? t('login.register') : t('login.signIn')}
|
||||
</button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock } from 'lucide-react'
|
||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react'
|
||||
import { authApi, adminApi } from '../api/client'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { UserWithOidc } from '../types'
|
||||
@@ -46,7 +46,7 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
|
||||
}
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout } = useAuthStore()
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
||||
@@ -79,6 +79,13 @@ export default function SettingsPage(): React.ReactElement {
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const [mfaQr, setMfaQr] = useState<string | null>(null)
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null)
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('')
|
||||
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
||||
const [mfaLoading, setMfaLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setDefaultLat(settings.default_lat || 48.8566)
|
||||
@@ -453,6 +460,145 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA */}
|
||||
<div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
||||
{demoMode ? (
|
||||
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium m-0" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
</p>
|
||||
|
||||
{!user?.mfa_enabled && !mfaQr && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const data = await authApi.mfaSetup() as { qr_data_url: string; secret: string }
|
||||
setMfaQr(data.qr_data_url)
|
||||
setMfaSecret(data.secret)
|
||||
setMfaSetupCode('')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('settings.mfa.setup')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!user?.mfa_enabled && mfaQr && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.scanQr')}</p>
|
||||
<img src={mfaQr} alt="" className="rounded-lg border mx-auto block" style={{ maxWidth: 200, borderColor: 'var(--border-primary)' }} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.secretLabel')}</label>
|
||||
<code className="block text-xs p-2 rounded break-all" style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}>{mfaSecret}</code>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaSetupCode}
|
||||
onChange={(e) => setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || mfaSetupCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
await authApi.mfaEnable({ code: mfaSetupCode })
|
||||
toast.success(t('settings.mfa.toastEnabled'))
|
||||
setMfaQr(null)
|
||||
setMfaSecret(null)
|
||||
setMfaSetupCode('')
|
||||
await loadUser()
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{t('settings.mfa.enable')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
|
||||
className="px-4 py-2 rounded-lg text-sm border"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('settings.mfa.cancelSetup')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.mfa_enabled && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p>
|
||||
<input
|
||||
type="password"
|
||||
value={mfaDisablePwd}
|
||||
onChange={(e) => setMfaDisablePwd(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaDisableCode}
|
||||
onChange={(e) => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
|
||||
toast.success(t('settings.mfa.toastDisabled'))
|
||||
setMfaDisablePwd('')
|
||||
setMfaDisableCode('')
|
||||
await loadUser()
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{t('settings.mfa.disable')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
{user?.avatar_url ? (
|
||||
|
||||
@@ -9,6 +9,8 @@ interface AuthResponse {
|
||||
token: string
|
||||
}
|
||||
|
||||
export type LoginResult = AuthResponse | { mfa_required: true; mfa_token: string }
|
||||
|
||||
interface AvatarResponse {
|
||||
avatar_url: string
|
||||
}
|
||||
@@ -22,7 +24,8 @@ interface AuthState {
|
||||
demoMode: boolean
|
||||
hasMapsKey: boolean
|
||||
|
||||
login: (email: string, password: string) => Promise<AuthResponse>
|
||||
login: (email: string, password: string) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
register: (username: string, email: string, password: string) => Promise<AuthResponse>
|
||||
logout: () => void
|
||||
loadUser: () => Promise<void>
|
||||
@@ -48,7 +51,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.login({ email, password })
|
||||
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
||||
if (data.mfa_required && data.mfa_token) {
|
||||
set({ isLoading: false, error: null })
|
||||
return { mfa_required: true as const, mfa_token: data.mfa_token }
|
||||
}
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
set({
|
||||
user: data.user,
|
||||
@@ -58,7 +65,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
error: null,
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Login failed')
|
||||
set({ isLoading: false, error })
|
||||
@@ -66,6 +73,27 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
completeMfaLogin: async (mfaToken: string, code: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
connect(data.token)
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Verification failed')
|
||||
set({ isLoading: false, error })
|
||||
throw new Error(error)
|
||||
}
|
||||
},
|
||||
|
||||
register: async (username: string, email: string, password: string, invite_token?: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface User {
|
||||
avatar_url: string | null
|
||||
maps_api_key: string | null
|
||||
created_at: string
|
||||
/** Present after load; true when TOTP MFA is enabled for password login */
|
||||
mfa_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface Trip {
|
||||
|
||||
376
server/package-lock.json
generated
376
server/package-lock.json
generated
@@ -19,6 +19,8 @@
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"unzipper": "^0.12.3",
|
||||
@@ -35,6 +37,7 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
@@ -457,6 +460,56 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
|
||||
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-thirty-two": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
|
||||
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"thirty-two": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-default": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
|
||||
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-v11": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
|
||||
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/archiver": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
|
||||
@@ -600,6 +653,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
@@ -697,6 +760,30 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -979,9 +1066,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1098,6 +1185,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -1129,6 +1225,35 @@
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compress-commons": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz",
|
||||
@@ -1252,6 +1377,15 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@@ -1304,6 +1438,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -1384,6 +1524,12 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
@@ -1595,6 +1741,19 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -1662,6 +1821,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -1757,9 +1925,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -1952,6 +2120,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -2084,6 +2261,18 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
@@ -2436,6 +2625,53 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/preset-default": "^12.0.1",
|
||||
"@otplib/preset-v11": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -2445,16 +2681,25 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2464,6 +2709,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
@@ -2527,6 +2781,23 @@
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
@@ -2611,9 +2882,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -2644,6 +2915,21 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
@@ -2736,6 +3022,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -2909,6 +3201,32 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
@@ -2989,6 +3307,14 @@
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/thirty-two": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
|
||||
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
|
||||
"engines": {
|
||||
"node": ">=0.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -3188,6 +3514,26 @@
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"node-fetch": "^2.7.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
@@ -34,6 +36,7 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
||||
@@ -194,6 +194,8 @@ function runMigrations(db: Database.Database): void {
|
||||
try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {}
|
||||
},
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch {}
|
||||
try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch {}
|
||||
},
|
||||
() => {
|
||||
|
||||
@@ -15,6 +15,8 @@ function createTables(db: Database.Database): void {
|
||||
oidc_sub TEXT,
|
||||
oidc_issuer TEXT,
|
||||
last_login DATETIME,
|
||||
mfa_enabled INTEGER DEFAULT 0,
|
||||
mfa_secret TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -6,11 +6,43 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import fetch from 'node-fetch';
|
||||
import { authenticator } from 'otplib';
|
||||
import QRCode from 'qrcode';
|
||||
import { db } from '../db/database';
|
||||
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
|
||||
import { AuthRequest, User } from '../types';
|
||||
|
||||
authenticator.options = { window: 1 };
|
||||
|
||||
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
|
||||
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
|
||||
|
||||
function getPendingMfaSecret(userId: number): string | null {
|
||||
const row = mfaSetupPending.get(userId);
|
||||
if (!row || Date.now() > row.exp) {
|
||||
mfaSetupPending.delete(userId);
|
||||
return null;
|
||||
}
|
||||
return row.secret;
|
||||
}
|
||||
|
||||
function stripUserForClient(user: User): Record<string, unknown> {
|
||||
const {
|
||||
password_hash: _p,
|
||||
maps_api_key: _m,
|
||||
openweather_api_key: _o,
|
||||
unsplash_api_key: _u,
|
||||
mfa_secret: _mf,
|
||||
...rest
|
||||
} = user;
|
||||
return {
|
||||
...rest,
|
||||
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
|
||||
};
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||
@@ -124,7 +156,7 @@ router.post('/demo-login', (_req: Request, res: Response) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@trek.app') as User | undefined;
|
||||
if (!user) return res.status(500).json({ error: 'Demo user not found' });
|
||||
const token = generateToken(user);
|
||||
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...safe } = user;
|
||||
const safe = stripUserForClient(user) as Record<string, unknown>;
|
||||
res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } });
|
||||
});
|
||||
|
||||
@@ -193,7 +225,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
||||
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
||||
).run(username, email, password_hash, role);
|
||||
|
||||
const user = { id: result.lastInsertRowid, username, email, role, avatar: null };
|
||||
const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false };
|
||||
const token = generateToken(user);
|
||||
|
||||
// Atomically increment invite token usage (prevents race condition)
|
||||
@@ -234,24 +266,34 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
if (user.mfa_enabled === 1 || user.mfa_enabled === true) {
|
||||
const mfa_token = jwt.sign(
|
||||
{ id: Number(user.id), purpose: 'mfa_login' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '5m' }
|
||||
);
|
||||
return res.json({ mfa_required: true, mfa_token });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||
const token = generateToken(user);
|
||||
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = user;
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
|
||||
res.json({ token, user: { ...userWithoutSensitive, avatar_url: avatarUrl(user) } });
|
||||
res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } });
|
||||
});
|
||||
|
||||
router.get('/me', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role, avatar, oidc_issuer, created_at FROM users WHERE id = ?'
|
||||
'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled FROM users WHERE id = ?'
|
||||
).get(authReq.user.id) as User | undefined;
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
|
||||
const base = stripUserForClient(user as User) as Record<string, unknown>;
|
||||
res.json({ user: { ...base, avatar_url: avatarUrl(user) } });
|
||||
});
|
||||
|
||||
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
||||
@@ -321,10 +363,11 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
|
||||
);
|
||||
|
||||
const updated = db.prepare(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
|
||||
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar'> | undefined;
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
|
||||
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
|
||||
|
||||
res.json({ success: true, user: { ...updated, maps_api_key: maskKey(updated?.maps_api_key), openweather_api_key: maskKey(updated?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
||||
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
|
||||
res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
||||
});
|
||||
|
||||
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||
@@ -368,10 +411,11 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const updated = db.prepare(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
|
||||
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar'> | undefined;
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
|
||||
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
|
||||
|
||||
res.json({ success: true, user: { ...updated, maps_api_key: maskKey(updated?.maps_api_key), openweather_api_key: maskKey(updated?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
||||
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
|
||||
res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
||||
});
|
||||
|
||||
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||
@@ -551,30 +595,107 @@ router.get('/travel-stats', authenticate, (req: Request, res: Response) => {
|
||||
});
|
||||
});
|
||||
|
||||
// GitHub releases proxy (cached, avoids client-side rate limits)
|
||||
let releasesCache: { data: unknown[]; fetchedAt: number } | null = null;
|
||||
const RELEASES_CACHE_TTL = 30 * 60 * 1000;
|
||||
|
||||
router.get('/github-releases', authenticate, async (req: Request, res: Response) => {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const perPage = Math.min(parseInt(req.query.per_page as string) || 10, 30);
|
||||
|
||||
if (page === 1 && releasesCache && Date.now() - releasesCache.fetchedAt < RELEASES_CACHE_TTL) {
|
||||
return res.json(releasesCache.data.slice(0, perPage));
|
||||
router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => {
|
||||
const { mfa_token, code } = req.body as { mfa_token?: string; code?: string };
|
||||
if (!mfa_token || !code) {
|
||||
return res.status(400).json({ error: 'Verification token and code are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`https://api.github.com/repos/mauriceboe/NOMAD/releases?per_page=${perPage}&page=${page}`,
|
||||
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
||||
);
|
||||
if (!resp.ok) return res.json([]);
|
||||
const data = await resp.json();
|
||||
if (page === 1) releasesCache = { data, fetchedAt: Date.now() };
|
||||
res.json(data);
|
||||
const decoded = jwt.verify(mfa_token, JWT_SECRET) as { id: number; purpose?: string };
|
||||
if (decoded.purpose !== 'mfa_login') {
|
||||
return res.status(401).json({ error: 'Invalid verification token' });
|
||||
}
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id) as User | undefined;
|
||||
if (!user || !(user.mfa_enabled === 1 || user.mfa_enabled === true) || !user.mfa_secret) {
|
||||
return res.status(401).json({ error: 'Invalid session' });
|
||||
}
|
||||
const secret = decryptMfaSecret(user.mfa_secret);
|
||||
const tokenStr = String(code).replace(/\s/g, '');
|
||||
const ok = authenticator.verify({ token: tokenStr, secret });
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid verification code' });
|
||||
}
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||
const sessionToken = generateToken(user);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to fetch releases' });
|
||||
return res.status(401).json({ error: 'Invalid or expired verification token' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/mfa/setup', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
|
||||
return res.status(403).json({ error: 'MFA is not available in demo mode.' });
|
||||
}
|
||||
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined;
|
||||
if (row?.mfa_enabled) {
|
||||
return res.status(400).json({ error: 'MFA is already enabled' });
|
||||
}
|
||||
const secret = authenticator.generateSecret();
|
||||
mfaSetupPending.set(authReq.user.id, { secret, exp: Date.now() + MFA_SETUP_TTL_MS });
|
||||
const otpauth_url = authenticator.keyuri(authReq.user.email, 'NOMAD', secret);
|
||||
QRCode.toDataURL(otpauth_url)
|
||||
.then((qr_data_url: string) => {
|
||||
res.json({ secret, otpauth_url, qr_data_url });
|
||||
})
|
||||
.catch(() => {
|
||||
res.status(500).json({ error: 'Could not generate QR code' });
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/mfa/enable', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { code } = req.body as { code?: string };
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Verification code is required' });
|
||||
}
|
||||
const pending = getPendingMfaSecret(authReq.user.id);
|
||||
if (!pending) {
|
||||
return res.status(400).json({ error: 'No MFA setup in progress. Start the setup again.' });
|
||||
}
|
||||
const tokenStr = String(code).replace(/\s/g, '');
|
||||
const ok = authenticator.verify({ token: tokenStr, secret: pending });
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid verification code' });
|
||||
}
|
||||
const enc = encryptMfaSecret(pending);
|
||||
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
enc,
|
||||
authReq.user.id
|
||||
);
|
||||
mfaSetupPending.delete(authReq.user.id);
|
||||
res.json({ success: true, mfa_enabled: true });
|
||||
});
|
||||
|
||||
router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
|
||||
return res.status(403).json({ error: 'MFA cannot be changed in demo mode.' });
|
||||
}
|
||||
const { password, code } = req.body as { password?: string; code?: string };
|
||||
if (!password || !code) {
|
||||
return res.status(400).json({ error: 'Password and authenticator code are required' });
|
||||
}
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(authReq.user.id) as User | undefined;
|
||||
if (!user?.mfa_enabled || !user.mfa_secret) {
|
||||
return res.status(400).json({ error: 'MFA is not enabled' });
|
||||
}
|
||||
if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Incorrect password' });
|
||||
}
|
||||
const secret = decryptMfaSecret(user.mfa_secret);
|
||||
const tokenStr = String(code).replace(/\s/g, '');
|
||||
const ok = authenticator.verify({ token: tokenStr, secret });
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid verification code' });
|
||||
}
|
||||
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
authReq.user.id
|
||||
);
|
||||
mfaSetupPending.delete(authReq.user.id);
|
||||
res.json({ success: true, mfa_enabled: false });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
25
server/src/services/mfaCrypto.ts
Normal file
25
server/src/services/mfaCrypto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import crypto from 'crypto';
|
||||
import { JWT_SECRET } from '../config';
|
||||
|
||||
function getKey(): Buffer {
|
||||
return crypto.createHash('sha256').update(`${JWT_SECRET}:mfa:v1`).digest();
|
||||
}
|
||||
|
||||
/** Encrypt TOTP secret for storage in SQLite. */
|
||||
export function encryptMfaSecret(plain: string): string {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv);
|
||||
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, enc]).toString('base64');
|
||||
}
|
||||
|
||||
export function decryptMfaSecret(blob: string): string {
|
||||
const buf = Buffer.from(blob, 'base64');
|
||||
const iv = buf.subarray(0, 12);
|
||||
const tag = buf.subarray(12, 28);
|
||||
const enc = buf.subarray(28);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export interface User {
|
||||
oidc_sub?: string | null;
|
||||
oidc_issuer?: string | null;
|
||||
last_login?: string | null;
|
||||
mfa_enabled?: number | boolean;
|
||||
mfa_secret?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user