- Rewrite SwipeTabs with improved touch handling and animation - Use translate3d with smoother easing curve (280ms) - Add coalesced pointer events for high refresh rate displays - Clean up redundant CSS styles across multiple files - Add page-max-width dimension variable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
404 lines
20 KiB
TypeScript
404 lines
20 KiB
TypeScript
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">
|
|
<div 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>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|