Add comprehensive backend features and mobile UI improvements
Backend: - Add 2FA authentication with TOTP support - Add API keys management system - Add audit logging for security events - Add file upload/management system - Add notifications system with preferences - Add session management - Add webhooks integration - Add analytics endpoints - Add export functionality - Add password policy enforcement - Add new database migrations for core tables Frontend: - Add module position system (top/bottom sidebar sections) - Add search and notifications module configuration tabs - Add mobile logo replacing hamburger menu - Center page title absolutely when no tabs present - Align sidebar footer toggles with navigation items - Add lighter icon color in dark theme for mobile - Add API keys management page - Add notifications page with context - Add admin analytics and audit logs pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,154 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import { sessionsAPI, twoFactorAPI } from '../api/client';
|
||||
import type { UserSession } from '../api/client';
|
||||
import '../styles/SettingsPage.css';
|
||||
|
||||
export default function Settings() {
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
const [twoFactorStatus, setTwoFactorStatus] = useState<{ enabled: boolean; has_backup_codes: boolean } | null>(null);
|
||||
const [twoFactorLoading, setTwoFactorLoading] = useState(true);
|
||||
const [twoFactorBusy, setTwoFactorBusy] = useState(false);
|
||||
const [twoFactorError, setTwoFactorError] = useState('');
|
||||
|
||||
const [setupData, setSetupData] = useState<{ secret: string; uri: string; qr_code: string } | null>(null);
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||
|
||||
const [regenerateCode, setRegenerateCode] = useState('');
|
||||
const [disablePassword, setDisablePassword] = useState('');
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||
const [sessionsBusy, setSessionsBusy] = useState(false);
|
||||
const [sessionsError, setSessionsError] = useState('');
|
||||
|
||||
const loadTwoFactorStatus = async () => {
|
||||
try {
|
||||
const status = await twoFactorAPI.getStatus();
|
||||
setTwoFactorStatus(status);
|
||||
} catch (err: any) {
|
||||
setTwoFactorError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setTwoFactorLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTwoFactorStatus();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadSessions = async () => {
|
||||
setSessionsError('');
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const data = await sessionsAPI.list();
|
||||
setSessions(data.items || []);
|
||||
} catch (err: any) {
|
||||
setSessionsError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const revokeSession = async (sessionId: string) => {
|
||||
setSessionsError('');
|
||||
setSessionsBusy(true);
|
||||
try {
|
||||
await sessionsAPI.revoke(sessionId);
|
||||
await loadSessions();
|
||||
} catch (err: any) {
|
||||
setSessionsError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setSessionsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeAllOtherSessions = async () => {
|
||||
setSessionsError('');
|
||||
setSessionsBusy(true);
|
||||
try {
|
||||
await sessionsAPI.revokeAllOther();
|
||||
await loadSessions();
|
||||
} catch (err: any) {
|
||||
setSessionsError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setSessionsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startTwoFactorSetup = async () => {
|
||||
setTwoFactorError('');
|
||||
setBackupCodes(null);
|
||||
setTwoFactorBusy(true);
|
||||
try {
|
||||
const data = await twoFactorAPI.setup();
|
||||
setSetupData(data);
|
||||
} catch (err: any) {
|
||||
setTwoFactorError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setTwoFactorBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyAndEnableTwoFactor = async () => {
|
||||
setTwoFactorError('');
|
||||
setTwoFactorBusy(true);
|
||||
try {
|
||||
const result = await twoFactorAPI.verify(verifyCode);
|
||||
setBackupCodes(result.backup_codes || []);
|
||||
setSetupData(null);
|
||||
setVerifyCode('');
|
||||
await loadTwoFactorStatus();
|
||||
} catch (err: any) {
|
||||
setTwoFactorError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setTwoFactorBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const regenerateTwoFactorBackupCodes = async () => {
|
||||
setTwoFactorError('');
|
||||
setTwoFactorBusy(true);
|
||||
try {
|
||||
const result = await twoFactorAPI.regenerateBackupCodes(regenerateCode);
|
||||
setBackupCodes(result.backup_codes || []);
|
||||
setRegenerateCode('');
|
||||
await loadTwoFactorStatus();
|
||||
} catch (err: any) {
|
||||
setTwoFactorError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setTwoFactorBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disableTwoFactor = async () => {
|
||||
setTwoFactorError('');
|
||||
setTwoFactorBusy(true);
|
||||
try {
|
||||
await twoFactorAPI.disable({ password: disablePassword, code: disableCode });
|
||||
setDisablePassword('');
|
||||
setDisableCode('');
|
||||
setBackupCodes(null);
|
||||
setSetupData(null);
|
||||
await loadTwoFactorStatus();
|
||||
} catch (err: any) {
|
||||
setTwoFactorError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setTwoFactorBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="main-content settings-page-root">
|
||||
<div className="page-tabs-container">
|
||||
@@ -21,32 +164,240 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
<div className="page-content settings-tab-content">
|
||||
<div className="settings-section-modern">
|
||||
|
||||
<div className="settings-grid">
|
||||
<div className="setting-item-modern">
|
||||
<div className="setting-info-modern">
|
||||
<div className="setting-icon-modern">
|
||||
<span className="material-symbols-outlined">language</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.settings.language}</h4>
|
||||
<p>{t.settings.languageDesc}</p>
|
||||
</div>
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">{t.settings.preferences}</h3>
|
||||
<div className="setting-item-modern">
|
||||
<div className="setting-info-modern">
|
||||
<div className="setting-icon-modern">
|
||||
<span className="material-symbols-outlined">language</span>
|
||||
</div>
|
||||
<div className="setting-control">
|
||||
<select
|
||||
className="select-modern"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as 'en' | 'it')}
|
||||
>
|
||||
<option value="en">{t.settings.english}</option>
|
||||
<option value="it">{t.settings.italian}</option>
|
||||
</select>
|
||||
<div className="setting-text">
|
||||
<h4>{t.settings.language}</h4>
|
||||
<p>{t.settings.languageDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-control">
|
||||
<select
|
||||
className="select-modern"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as 'en' | 'it')}
|
||||
>
|
||||
<option value="en">{t.settings.english}</option>
|
||||
<option value="it">{t.settings.italian}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">{t.settings.security}</h3>
|
||||
|
||||
<div className="setting-item-modern">
|
||||
<div className="setting-info-modern">
|
||||
<div className="setting-icon-modern">
|
||||
<span className="material-symbols-outlined">shield</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.settings.twoFactorTitle}</h4>
|
||||
<p>{t.settings.twoFactorDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-control">
|
||||
{twoFactorLoading ? (
|
||||
<span className="badge badge-neutral">{t.common.loading}</span>
|
||||
) : (
|
||||
<span className={`badge ${twoFactorStatus?.enabled ? 'badge-success' : 'badge-neutral'}`}>
|
||||
{twoFactorStatus?.enabled ? t.settings.enabled : t.settings.disabled}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{twoFactorError && <div className="error-message" style={{ marginTop: '1rem' }}>{twoFactorError}</div>}
|
||||
|
||||
{!twoFactorLoading && twoFactorStatus && (
|
||||
<div className="settings-security-details">
|
||||
{!twoFactorStatus.enabled && (
|
||||
<>
|
||||
{!setupData ? (
|
||||
<button className="btn-primary" onClick={startTwoFactorSetup} disabled={twoFactorBusy}>
|
||||
{t.settings.enable2fa}
|
||||
</button>
|
||||
) : (
|
||||
<div className="settings-twofa-setup">
|
||||
<div className="settings-twofa-qr">
|
||||
<img
|
||||
src={`data:image/png;base64,${setupData.qr_code}`}
|
||||
alt={t.settings.qrCodeAlt}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-twofa-meta">
|
||||
<div className="settings-twofa-secret">
|
||||
<div className="settings-twofa-secret-label">{t.settings.secret}</div>
|
||||
<code className="settings-twofa-secret-value">{setupData.secret}</code>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginTop: '1rem' }}>
|
||||
<label htmlFor="verifyCode">{t.settings.verificationCode}</label>
|
||||
<input
|
||||
id="verifyCode"
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
minLength={6}
|
||||
maxLength={8}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-twofa-actions">
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={verifyAndEnableTwoFactor}
|
||||
disabled={twoFactorBusy || verifyCode.trim().length < 6}
|
||||
>
|
||||
{t.settings.verifyEnable2fa}
|
||||
</button>
|
||||
<button
|
||||
className="btn-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSetupData(null);
|
||||
setVerifyCode('');
|
||||
setTwoFactorError('');
|
||||
}}
|
||||
disabled={twoFactorBusy}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{twoFactorStatus.enabled && (
|
||||
<div className="settings-twofa-actions-grid">
|
||||
<div className="settings-twofa-action">
|
||||
<h4>{t.settings.backupCodes}</h4>
|
||||
<p>{t.settings.backupCodesDesc}</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="regenerateCode">{t.settings.verificationCode}</label>
|
||||
<input
|
||||
id="regenerateCode"
|
||||
type="text"
|
||||
value={regenerateCode}
|
||||
onChange={(e) => setRegenerateCode(e.target.value)}
|
||||
minLength={6}
|
||||
maxLength={8}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={regenerateTwoFactorBackupCodes}
|
||||
disabled={twoFactorBusy || regenerateCode.trim().length < 6}
|
||||
>
|
||||
{t.settings.regenerateBackupCodes}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-twofa-action danger">
|
||||
<h4>{t.settings.disable2fa}</h4>
|
||||
<p>{t.settings.disable2faDesc}</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="disablePassword">{t.auth.password}</label>
|
||||
<input
|
||||
id="disablePassword"
|
||||
type="password"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="disableCode">{t.settings.verificationCode}</label>
|
||||
<input
|
||||
id="disableCode"
|
||||
type="text"
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value)}
|
||||
minLength={6}
|
||||
maxLength={8}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn-danger"
|
||||
onClick={disableTwoFactor}
|
||||
disabled={twoFactorBusy || !disablePassword || disableCode.trim().length < 6}
|
||||
>
|
||||
{t.settings.disable2faConfirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className="settings-backup-codes-section">
|
||||
<h4>{t.settings.backupCodes}</h4>
|
||||
<p>{t.settings.backupCodesSaveHint}</p>
|
||||
<div className="settings-backup-codes">
|
||||
{backupCodes.map((code) => (
|
||||
<code key={code} className="settings-backup-code">{code}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">{t.settings.sessionsTitle}</h3>
|
||||
<p className="settings-section-desc">{t.settings.sessionsDesc}</p>
|
||||
|
||||
{sessionsError && <div className="error-message">{sessionsError}</div>}
|
||||
|
||||
<div className="settings-sessions-header">
|
||||
<button className="btn-danger" onClick={revokeAllOtherSessions} disabled={sessionsLoading || sessionsBusy}>
|
||||
{t.settings.revokeAllOtherSessions}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sessionsLoading ? (
|
||||
<div className="loading">{t.common.loading}</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="settings-empty">{t.settings.sessionsEmpty}</div>
|
||||
) : (
|
||||
<div className="settings-sessions-list">
|
||||
{sessions.map((s) => (
|
||||
<div key={s.id} className="settings-session-row">
|
||||
<div className="settings-session-meta">
|
||||
<div className="settings-session-title">
|
||||
<span className="settings-session-device">{s.device_name || t.settings.unknownDevice}</span>
|
||||
{s.is_current && <span className="badge badge-accent">{t.settings.currentSession}</span>}
|
||||
{!s.is_active && <span className="badge badge-muted">{t.settings.inactiveSession}</span>}
|
||||
</div>
|
||||
<div className="settings-session-details">
|
||||
<span>{s.browser || t.settings.unknownBrowser} • {s.os || t.settings.unknownOs}</span>
|
||||
{s.ip_address && <span> • {s.ip_address}</span>}
|
||||
</div>
|
||||
<div className="settings-session-details">
|
||||
<span>{t.settings.lastActive}: {new Date(s.last_active_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-session-actions">
|
||||
{!s.is_current && s.is_active && (
|
||||
<button className="btn-danger" onClick={() => revokeSession(s.id)} disabled={sessionsBusy}>
|
||||
{t.settings.revokeSession}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user