Files
app-service/frontend/src/pages/Settings.tsx

405 lines
20 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import TabsScroller from '../components/TabsScroller';
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">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.settings.title}</span>
</div>
</TabsScroller>
</div>
<div className="page-content page-content--narrow settings-tab-content">
<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-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>
);
}