808 lines
36 KiB
JavaScript
808 lines
36 KiB
JavaScript
import { useState, useRef, useEffect } from 'react';
|
|
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff, Shield, ShieldCheck, ShieldOff } from 'lucide-react';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { useTheme } from '../contexts/ThemeContext';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import { themes, getThemeGroups } from '../themes';
|
|
import api from '../services/api';
|
|
import toast from 'react-hot-toast';
|
|
|
|
export default function Settings() {
|
|
const { user, updateUser } = useAuth();
|
|
const { theme, setTheme } = useTheme();
|
|
const { t, language, setLanguage } = useLanguage();
|
|
|
|
const [profile, setProfile] = useState({
|
|
name: user?.name || '',
|
|
display_name: user?.display_name || '',
|
|
email: user?.email || '',
|
|
});
|
|
const [passwords, setPasswords] = useState({
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
});
|
|
const [savingProfile, setSavingProfile] = useState(false);
|
|
const [savingPassword, setSavingPassword] = useState(false);
|
|
|
|
const handleLanguageChange = async (lang) => {
|
|
setLanguage(lang);
|
|
try {
|
|
const res = await api.put('/auth/profile', { language: lang });
|
|
updateUser(res.data.user);
|
|
} catch {
|
|
// Language is still saved locally even if API fails
|
|
}
|
|
};
|
|
const [activeSection, setActiveSection] = useState('profile');
|
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
|
const fileInputRef = useRef(null);
|
|
|
|
// CalDAV token state
|
|
const [caldavTokens, setCaldavTokens] = useState([]);
|
|
const [caldavLoading, setCaldavLoading] = useState(false);
|
|
const [newTokenName, setNewTokenName] = useState('');
|
|
const [creatingToken, setCreatingToken] = useState(false);
|
|
const [newlyCreatedToken, setNewlyCreatedToken] = useState(null);
|
|
const [tokenVisible, setTokenVisible] = useState(false);
|
|
|
|
// 2FA state
|
|
const [twoFaEnabled, setTwoFaEnabled] = useState(!!user?.totp_enabled);
|
|
const [twoFaLoading, setTwoFaLoading] = useState(false);
|
|
const [twoFaSetupData, setTwoFaSetupData] = useState(null); // { secret, uri, qrDataUrl }
|
|
const [twoFaCode, setTwoFaCode] = useState('');
|
|
const [twoFaEnabling, setTwoFaEnabling] = useState(false);
|
|
const [twoFaDisablePassword, setTwoFaDisablePassword] = useState('');
|
|
const [twoFaDisableCode, setTwoFaDisableCode] = useState('');
|
|
const [twoFaDisabling, setTwoFaDisabling] = useState(false);
|
|
const [showDisableForm, setShowDisableForm] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (activeSection === 'caldav') {
|
|
setCaldavLoading(true);
|
|
api.get('/calendar/caldav-tokens')
|
|
.then(r => setCaldavTokens(r.data.tokens || []))
|
|
.catch(() => {})
|
|
.finally(() => setCaldavLoading(false));
|
|
}
|
|
if (activeSection === 'security') {
|
|
setTwoFaLoading(true);
|
|
api.get('/auth/2fa/status')
|
|
.then(r => setTwoFaEnabled(r.data.enabled))
|
|
.catch(() => {})
|
|
.finally(() => setTwoFaLoading(false));
|
|
}
|
|
}, [activeSection]);
|
|
|
|
const handleCreateToken = async (e) => {
|
|
e.preventDefault();
|
|
if (!newTokenName.trim()) return;
|
|
setCreatingToken(true);
|
|
try {
|
|
const res = await api.post('/calendar/caldav-tokens', { name: newTokenName.trim() });
|
|
setNewlyCreatedToken(res.data.plainToken);
|
|
setTokenVisible(false);
|
|
setNewTokenName('');
|
|
const r = await api.get('/calendar/caldav-tokens');
|
|
setCaldavTokens(r.data.tokens || []);
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('settings.caldav.createFailed'));
|
|
} finally {
|
|
setCreatingToken(false);
|
|
}
|
|
};
|
|
|
|
const handleRevokeToken = async (id) => {
|
|
if (!confirm(t('settings.caldav.revokeConfirm'))) return;
|
|
try {
|
|
await api.delete(`/calendar/caldav-tokens/${id}`);
|
|
setCaldavTokens(prev => prev.filter(tk => tk.id !== id));
|
|
toast.success(t('settings.caldav.revoked'));
|
|
} catch {
|
|
toast.error(t('settings.caldav.revokeFailed'));
|
|
}
|
|
};
|
|
|
|
// 2FA handlers
|
|
const handleSetup2FA = async () => {
|
|
setTwoFaLoading(true);
|
|
try {
|
|
const res = await api.post('/auth/2fa/setup');
|
|
// Generate QR code data URL client-side
|
|
const QRCode = (await import('qrcode')).default;
|
|
const qrDataUrl = await QRCode.toDataURL(res.data.uri, { width: 200, margin: 2, color: { dark: '#000000', light: '#ffffff' } });
|
|
setTwoFaSetupData({ secret: res.data.secret, uri: res.data.uri, qrDataUrl });
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('settings.security.setupFailed'));
|
|
} finally {
|
|
setTwoFaLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleEnable2FA = async (e) => {
|
|
e.preventDefault();
|
|
setTwoFaEnabling(true);
|
|
try {
|
|
await api.post('/auth/2fa/enable', { code: twoFaCode });
|
|
setTwoFaEnabled(true);
|
|
setTwoFaSetupData(null);
|
|
setTwoFaCode('');
|
|
toast.success(t('settings.security.enabled'));
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('settings.security.enableFailed'));
|
|
setTwoFaCode('');
|
|
} finally {
|
|
setTwoFaEnabling(false);
|
|
}
|
|
};
|
|
|
|
const handleDisable2FA = async (e) => {
|
|
e.preventDefault();
|
|
setTwoFaDisabling(true);
|
|
try {
|
|
await api.post('/auth/2fa/disable', { password: twoFaDisablePassword, code: twoFaDisableCode });
|
|
setTwoFaEnabled(false);
|
|
setShowDisableForm(false);
|
|
setTwoFaDisablePassword('');
|
|
setTwoFaDisableCode('');
|
|
toast.success(t('settings.security.disabled'));
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('settings.security.disableFailed'));
|
|
} finally {
|
|
setTwoFaDisabling(false);
|
|
}
|
|
};
|
|
|
|
const groups = getThemeGroups();
|
|
|
|
const avatarColors = [
|
|
'#6366f1', '#8b5cf6', '#a855f7', '#d946ef',
|
|
'#ec4899', '#f43f5e', '#ef4444', '#f97316',
|
|
'#eab308', '#22c55e', '#14b8a6', '#06b6d4',
|
|
'#3b82f6', '#2563eb', '#7c3aed', '#64748b',
|
|
];
|
|
|
|
const handleProfileSave = async (e) => {
|
|
e.preventDefault();
|
|
setSavingProfile(true);
|
|
try {
|
|
const res = await api.put('/auth/profile', {
|
|
name: profile.name,
|
|
display_name: profile.display_name,
|
|
email: profile.email,
|
|
theme,
|
|
language,
|
|
avatar_color: user?.avatar_color,
|
|
});
|
|
updateUser(res.data.user);
|
|
toast.success(t('settings.profileSaved'));
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('settings.profileSaveFailed'));
|
|
} finally {
|
|
setSavingProfile(false);
|
|
}
|
|
};
|
|
|
|
const handlePasswordSave = async (e) => {
|
|
e.preventDefault();
|
|
if (passwords.newPassword !== passwords.confirmPassword) {
|
|
toast.error(t('settings.passwordMismatch'));
|
|
return;
|
|
}
|
|
setSavingPassword(true);
|
|
try {
|
|
await api.put('/auth/password', {
|
|
currentPassword: passwords.currentPassword,
|
|
newPassword: passwords.newPassword,
|
|
});
|
|
setPasswords({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
|
toast.success(t('settings.passwordChanged'));
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('settings.passwordChangeFailed'));
|
|
} finally {
|
|
setSavingPassword(false);
|
|
}
|
|
};
|
|
|
|
const handleAvatarColor = async (color) => {
|
|
try {
|
|
const res = await api.put('/auth/profile', { avatar_color: color });
|
|
updateUser(res.data.user);
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
};
|
|
|
|
const handleAvatarUpload = async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
if (!file.type.startsWith('image/')) {
|
|
toast.error(t('settings.avatarInvalidType'));
|
|
return;
|
|
}
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
toast.error(t('settings.avatarTooLarge'));
|
|
return;
|
|
}
|
|
setUploadingAvatar(true);
|
|
try {
|
|
const res = await api.post('/auth/avatar', file, {
|
|
headers: { 'Content-Type': file.type },
|
|
});
|
|
updateUser(res.data.user);
|
|
toast.success(t('settings.avatarUploaded'));
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('settings.avatarUploadFailed'));
|
|
} finally {
|
|
setUploadingAvatar(false);
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handleAvatarRemove = async () => {
|
|
try {
|
|
const res = await api.delete('/auth/avatar');
|
|
updateUser(res.data.user);
|
|
toast.success(t('settings.avatarRemoved'));
|
|
} catch {
|
|
toast.error(t('settings.avatarRemoveFailed'));
|
|
}
|
|
};
|
|
|
|
const sections = [
|
|
{ id: 'profile', label: t('settings.profile'), icon: User },
|
|
{ id: 'password', label: t('settings.password'), icon: Lock },
|
|
{ id: 'security', label: t('settings.security.title'), icon: Shield },
|
|
{ id: 'language', label: t('settings.language'), icon: Globe },
|
|
{ id: 'themes', label: t('settings.themes'), icon: Palette },
|
|
{ id: 'caldav', label: t('settings.caldav.title'), icon: Calendar },
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold text-th-text">{t('settings.title')}</h1>
|
|
<p className="text-sm text-th-text-s mt-1">{t('settings.subtitle')}</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col md:flex-row gap-6">
|
|
{/* Section nav */}
|
|
<div className="md:w-56 flex-shrink-0">
|
|
<nav className="flex md:flex-col gap-1">
|
|
{sections.map(s => (
|
|
<button
|
|
key={s.id}
|
|
onClick={() => setActiveSection(s.id)}
|
|
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
|
activeSection === s.id
|
|
? 'bg-th-accent text-th-accent-t'
|
|
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
|
|
}`}
|
|
>
|
|
<s.icon size={16} />
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 max-w-2xl">
|
|
{/* Profile section */}
|
|
{activeSection === 'profile' && (
|
|
<div className="card p-6">
|
|
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.editProfile')}</h2>
|
|
|
|
{/* Avatar */}
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium text-th-text mb-3">{t('settings.avatar')}</label>
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative group">
|
|
{user?.avatar_image ? (
|
|
<img
|
|
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
|
alt="Avatar"
|
|
className="w-16 h-16 rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
<div
|
|
className="w-16 h-16 rounded-full flex items-center justify-center text-white text-xl font-bold"
|
|
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
|
|
>
|
|
{(user?.display_name || user?.name)?.[0]?.toUpperCase() || '?'}
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploadingAvatar}
|
|
className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
|
>
|
|
{uploadingAvatar ? (
|
|
<Loader2 size={20} className="text-white animate-spin" />
|
|
) : (
|
|
<Camera size={20} className="text-white" />
|
|
)}
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleAvatarUpload}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="btn-ghost text-xs py-1.5 px-3"
|
|
>
|
|
<Camera size={14} />
|
|
{t('settings.uploadImage')}
|
|
</button>
|
|
{user?.avatar_image && (
|
|
<button
|
|
type="button"
|
|
onClick={handleAvatarRemove}
|
|
className="btn-ghost text-xs py-1.5 px-3 text-th-error hover:text-th-error"
|
|
>
|
|
<X size={14} />
|
|
{t('settings.removeImage')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-th-text-s">{t('settings.avatarHint')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Avatar Color (fallback) */}
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium text-th-text mb-3">{t('settings.avatarColor')}</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{avatarColors.map(color => (
|
|
<button
|
|
key={color}
|
|
onClick={() => handleAvatarColor(color)}
|
|
className={`w-7 h-7 rounded-full ring-2 ring-offset-2 transition-all ${
|
|
user?.avatar_color === color ? 'ring-th-accent' : 'ring-transparent hover:ring-th-border'
|
|
}`}
|
|
style={{ backgroundColor: color, '--tw-ring-offset-color': 'var(--bg-primary)' }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-th-text-s mt-2">{t('settings.avatarColorHint')}</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleProfileSave} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
|
|
<div className="relative">
|
|
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="text"
|
|
value={profile.name}
|
|
onChange={e => setProfile({ ...profile, name: e.target.value })}
|
|
className="input-field pl-11"
|
|
required
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
|
|
<div className="relative">
|
|
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="text"
|
|
value={profile.display_name}
|
|
onChange={e => setProfile({ ...profile, display_name: e.target.value })}
|
|
className="input-field pl-11"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
|
<div className="relative">
|
|
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="email"
|
|
value={profile.email}
|
|
onChange={e => setProfile({ ...profile, email: e.target.value })}
|
|
className="input-field pl-11"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button type="submit" disabled={savingProfile} className="btn-primary">
|
|
{savingProfile ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
|
{t('common.save')}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Password section */}
|
|
{activeSection === 'password' && (
|
|
<div className="card p-6">
|
|
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.changePassword')}</h2>
|
|
<form onSubmit={handlePasswordSave} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
|
|
<div className="relative">
|
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="password"
|
|
value={passwords.currentPassword}
|
|
onChange={e => setPasswords({ ...passwords, currentPassword: e.target.value })}
|
|
className="input-field pl-11"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.newPassword')}</label>
|
|
<div className="relative">
|
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="password"
|
|
value={passwords.newPassword}
|
|
onChange={e => setPasswords({ ...passwords, newPassword: e.target.value })}
|
|
className="input-field pl-11"
|
|
placeholder={t('auth.minPassword')}
|
|
required
|
|
minLength={6}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.confirmNewPassword')}</label>
|
|
<div className="relative">
|
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="password"
|
|
value={passwords.confirmPassword}
|
|
onChange={e => setPasswords({ ...passwords, confirmPassword: e.target.value })}
|
|
className="input-field pl-11"
|
|
placeholder={t('auth.repeatPassword')}
|
|
required
|
|
minLength={6}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button type="submit" disabled={savingPassword} className="btn-primary">
|
|
{savingPassword ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
|
{t('settings.changePassword')}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Security / 2FA section */}
|
|
{activeSection === 'security' && (
|
|
<div className="space-y-5">
|
|
<div className="card p-6">
|
|
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.security.title')}</h2>
|
|
<p className="text-sm text-th-text-s mb-6">{t('settings.security.subtitle')}</p>
|
|
|
|
{twoFaLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 size={24} className="animate-spin text-th-text-s" />
|
|
</div>
|
|
) : twoFaEnabled ? (
|
|
/* 2FA is enabled */
|
|
<div>
|
|
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 mb-5">
|
|
<ShieldCheck size={22} className="text-emerald-400 flex-shrink-0" />
|
|
<div>
|
|
<p className="text-sm font-medium text-emerald-300">{t('settings.security.statusEnabled')}</p>
|
|
<p className="text-xs text-emerald-400/70">{t('settings.security.statusEnabledDesc')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{!showDisableForm ? (
|
|
<button
|
|
onClick={() => setShowDisableForm(true)}
|
|
className="btn-ghost text-th-error hover:text-th-error text-sm"
|
|
>
|
|
<ShieldOff size={16} />
|
|
{t('settings.security.disable')}
|
|
</button>
|
|
) : (
|
|
<form onSubmit={handleDisable2FA} className="space-y-4 p-4 rounded-xl bg-th-bg-t border border-th-border">
|
|
<p className="text-sm text-th-text-s">{t('settings.security.disableConfirm')}</p>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
|
|
<div className="relative">
|
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="password"
|
|
value={twoFaDisablePassword}
|
|
onChange={e => setTwoFaDisablePassword(e.target.value)}
|
|
className="input-field pl-11"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.codeLabel')}</label>
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
value={twoFaDisableCode}
|
|
onChange={e => setTwoFaDisableCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
|
|
className="input-field text-center text-lg tracking-[0.3em] font-mono"
|
|
placeholder="000 000"
|
|
required
|
|
maxLength={7}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button type="submit" disabled={twoFaDisabling} className="btn-primary bg-red-600 hover:bg-red-700 border-red-600">
|
|
{twoFaDisabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
|
|
{t('settings.security.disable')}
|
|
</button>
|
|
<button type="button" onClick={() => { setShowDisableForm(false); setTwoFaDisablePassword(''); setTwoFaDisableCode(''); }} className="btn-ghost text-sm">
|
|
{t('common.cancel')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
) : twoFaSetupData ? (
|
|
/* Setup flow: show QR code + verification */
|
|
<div className="space-y-5">
|
|
<div className="text-center">
|
|
<p className="text-sm text-th-text mb-4">{t('settings.security.scanQR')}</p>
|
|
<div className="inline-block p-3 bg-white rounded-xl">
|
|
<img src={twoFaSetupData.qrDataUrl} alt="TOTP QR Code" className="w-[200px] h-[200px]" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.security.manualKey')}</p>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono break-all">
|
|
{twoFaSetupData.secret}
|
|
</code>
|
|
<button
|
|
onClick={() => { navigator.clipboard.writeText(twoFaSetupData.secret); toast.success(t('room.linkCopied')); }}
|
|
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
|
>
|
|
<Copy size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<form onSubmit={handleEnable2FA} className="space-y-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.verifyCode')}</label>
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
value={twoFaCode}
|
|
onChange={e => setTwoFaCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
|
|
className="input-field text-center text-lg tracking-[0.3em] font-mono"
|
|
placeholder="000 000"
|
|
required
|
|
maxLength={7}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button type="submit" disabled={twoFaEnabling || twoFaCode.replace(/\s/g, '').length < 6} className="btn-primary">
|
|
{twoFaEnabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldCheck size={14} />}
|
|
{t('settings.security.enable')}
|
|
</button>
|
|
<button type="button" onClick={() => { setTwoFaSetupData(null); setTwoFaCode(''); }} className="btn-ghost text-sm">
|
|
{t('common.cancel')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
) : (
|
|
/* 2FA is disabled — show enable button */
|
|
<div>
|
|
<div className="flex items-center gap-3 p-4 rounded-xl bg-th-bg-t border border-th-border mb-5">
|
|
<ShieldOff size={22} className="text-th-text-s flex-shrink-0" />
|
|
<div>
|
|
<p className="text-sm font-medium text-th-text">{t('settings.security.statusDisabled')}</p>
|
|
<p className="text-xs text-th-text-s">{t('settings.security.statusDisabledDesc')}</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={handleSetup2FA} disabled={twoFaLoading} className="btn-primary">
|
|
{twoFaLoading ? <Loader2 size={16} className="animate-spin" /> : <Shield size={16} />}
|
|
{t('settings.security.enable')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Language section */}
|
|
{activeSection === 'language' && (
|
|
<div className="card p-6">
|
|
<h2 className="text-lg font-semibold text-th-text mb-2">{t('settings.selectLanguage')}</h2>
|
|
<p className="text-sm text-th-text-s mb-6">{t('settings.subtitle')}</p>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{[
|
|
{ code: 'en', label: 'English', flag: '🇬🇧' },
|
|
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
|
].map(lang => (
|
|
<button
|
|
key={lang.code}
|
|
onClick={() => handleLanguageChange(lang.code)}
|
|
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-all ${
|
|
language === lang.code
|
|
? 'border-th-accent shadow-md bg-th-accent/5'
|
|
: 'border-transparent hover:border-th-border'
|
|
}`}
|
|
>
|
|
<span className="text-2xl">{lang.flag}</span>
|
|
<span className="text-sm font-medium text-th-text">{lang.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Themes section */}
|
|
{activeSection === 'themes' && (
|
|
<div className="space-y-6">
|
|
{Object.entries(groups).map(([groupName, groupThemes]) => (
|
|
<div key={groupName} className="card p-5">
|
|
<h3 className="text-sm font-semibold text-th-text-s uppercase tracking-wider mb-4">{groupName}</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{groupThemes.map(th => (
|
|
<button
|
|
key={th.id}
|
|
onClick={() => setTheme(th.id)}
|
|
className={`flex items-center gap-3 p-3 rounded-xl border-2 transition-all ${
|
|
theme === th.id
|
|
? 'border-th-accent shadow-md'
|
|
: 'border-transparent hover:border-th-border'
|
|
}`}
|
|
>
|
|
{/* Color preview */}
|
|
<div
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 border"
|
|
style={{ backgroundColor: th.colors.bg, borderColor: th.colors.accent + '40' }}
|
|
>
|
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: th.colors.accent }} />
|
|
</div>
|
|
<div className="text-left">
|
|
<p className="text-sm font-medium text-th-text">{th.name}</p>
|
|
<p className="text-xs text-th-text-s capitalize">{th.type === 'light' ? t('themes.light') : t('themes.dark')}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* CalDAV section */}
|
|
{activeSection === 'caldav' && (
|
|
<div className="space-y-5">
|
|
{/* Info Card */}
|
|
<div className="card p-6">
|
|
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.caldav.title')}</h2>
|
|
<p className="text-sm text-th-text-s mb-5">{t('settings.caldav.subtitle')}</p>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.caldav.serverUrl')}</p>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono truncate">
|
|
{`${window.location.origin}/caldav/`}
|
|
</code>
|
|
<button
|
|
onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/caldav/`); toast.success(t('room.linkCopied')); }}
|
|
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
|
>
|
|
<Copy size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.caldav.username')}</p>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-text font-mono">
|
|
{user?.email}
|
|
</code>
|
|
<button
|
|
onClick={() => { navigator.clipboard.writeText(user?.email || ''); toast.success(t('room.linkCopied')); }}
|
|
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
|
>
|
|
<Copy size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-th-text-s">{t('settings.caldav.hint')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* New token was just created */}
|
|
{newlyCreatedToken && (
|
|
<div className="card p-5 border-2 border-th-success/40 bg-th-success/5">
|
|
<p className="text-sm font-semibold text-th-success mb-2">{t('settings.caldav.newTokenCreated')}</p>
|
|
<p className="text-xs text-th-text-s mb-3">{t('settings.caldav.newTokenHint')}</p>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg font-mono text-th-text break-all">
|
|
{tokenVisible ? newlyCreatedToken : '•'.repeat(48)}
|
|
</code>
|
|
<button onClick={() => setTokenVisible(v => !v)} className="btn-ghost py-1.5 px-2 flex-shrink-0">
|
|
{tokenVisible ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
</button>
|
|
<button
|
|
onClick={() => { navigator.clipboard.writeText(newlyCreatedToken); toast.success(t('room.linkCopied')); }}
|
|
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
|
>
|
|
<Copy size={14} />
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => setNewlyCreatedToken(null)}
|
|
className="mt-3 text-xs text-th-text-s hover:text-th-text underline"
|
|
>
|
|
{t('settings.caldav.dismiss')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create new token */}
|
|
<div className="card p-5">
|
|
<h3 className="text-sm font-semibold text-th-text mb-3">{t('settings.caldav.newToken')}</h3>
|
|
<form onSubmit={handleCreateToken} className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={newTokenName}
|
|
onChange={e => setNewTokenName(e.target.value)}
|
|
placeholder={t('settings.caldav.tokenNamePlaceholder')}
|
|
className="input-field flex-1 text-sm"
|
|
required
|
|
/>
|
|
<button type="submit" disabled={creatingToken || !newTokenName.trim()} className="btn-primary py-1.5 px-4">
|
|
{creatingToken ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
|
{t('settings.caldav.generate')}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Token list */}
|
|
<div className="card p-5">
|
|
<h3 className="text-sm font-semibold text-th-text mb-3">{t('settings.caldav.existingTokens')}</h3>
|
|
{caldavLoading ? (
|
|
<div className="flex items-center justify-center py-6"><Loader2 size={20} className="animate-spin text-th-text-s" /></div>
|
|
) : caldavTokens.length === 0 ? (
|
|
<p className="text-sm text-th-text-s py-3">{t('settings.caldav.noTokens')}</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{caldavTokens.map(tk => (
|
|
<div key={tk.id} className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg bg-th-bg-t">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-th-text truncate">{tk.name}</p>
|
|
<p className="text-xs text-th-text-s">
|
|
{t('settings.caldav.created')}: {new Date(tk.created_at).toLocaleDateString()}
|
|
{tk.last_used_at && ` · ${t('settings.caldav.lastUsed')}: ${new Date(tk.last_used_at).toLocaleDateString()}`}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => handleRevokeToken(tk.id)}
|
|
className="btn-ghost py-1 px-2 text-th-error hover:text-th-error flex-shrink-0"
|
|
title={t('settings.caldav.revoke')}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div> </div>
|
|
</div>
|
|
);
|
|
} |