feat(caldav): implement CalDAV support with token management and calendar operations
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m5s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m5s
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -38,6 +38,53 @@ export default function Settings() {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === 'caldav') {
|
||||
setCaldavLoading(true);
|
||||
api.get('/calendar/caldav-tokens')
|
||||
.then(r => setCaldavTokens(r.data.tokens || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setCaldavLoading(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'));
|
||||
}
|
||||
};
|
||||
|
||||
const groups = getThemeGroups();
|
||||
|
||||
const avatarColors = [
|
||||
@@ -139,6 +186,7 @@ export default function Settings() {
|
||||
{ id: 'password', label: t('settings.password'), icon: Lock },
|
||||
{ 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 (
|
||||
@@ -425,8 +473,126 @@ export default function Settings() {
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user