feat(ical): abonnierbarer read-only Kalender-Feed pro Nutzer
Token-gescoper iCal-Feed (/absences/ical/<token>.ics), abonnierbar in Outlook/Apple/Google. Anders als der CalDAV-Client (Push nach Nextcloud) pollt der Kalender die URL selbst. Feed zeigt nur die eigenen bestätigten Abwesenheiten des Token-Inhabers. - users.ical_token_hash (SHA-256, rotierbar) + Migration 0041 (nur Spalte, keine RLS-Aenderung; users-Policy deckt neue nullable Spalte ab) - Router ical.py: oeffentlicher Feed (kein JWT) + Token-Verwaltung POST/GET/DELETE /users/me/ical-token (authentifiziert) - ProfilePage: Sektion "Kalender-Abo (iCal)" mit Erzeugen/Rotieren/ Deaktivieren, URL-Anzeige einmalig + Kopieren - test_ical.py: Token-Lifecycle + oeffentlicher Feed (3 Tests) Deployed auf 137 (Migration 0041, 196/196 Tests gruen). 164 ausstehend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -167,6 +167,90 @@ function TotpSection({ enabled, onToggle }: { enabled: boolean; onToggle: () =>
|
||||
)
|
||||
}
|
||||
|
||||
// ── iCal-Abo ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function IcalSection() {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [url, setUrl] = useState('') // nur direkt nach Erzeugen bekannt
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ enabled: boolean }>('/users/me/ical-token')
|
||||
.then(r => setEnabled(r.enabled)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
async function createOrRotate() {
|
||||
setBusy(true)
|
||||
try {
|
||||
const r = await api.post<{ enabled: boolean; url: string }>('/users/me/ical-token', {})
|
||||
setEnabled(true); setUrl(r.url); setCopied(false)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.del('/users/me/ical-token')
|
||||
setEnabled(false); setUrl('')
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
|
||||
<div className='mb-4'>
|
||||
<h2 className='font-semibold text-gray-800'>Kalender-Abo (iCal)</h2>
|
||||
<p className='text-sm text-gray-400 mt-0.5'>
|
||||
Abonniere deine genehmigten Abwesenheiten schreibgeschützt in Outlook, Apple oder
|
||||
Google Kalender. Die URL enthält ein geheimes Token – nicht weitergeben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{url && (
|
||||
<div className='mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3'>
|
||||
<p className='text-xs font-medium text-blue-800 mb-1'>
|
||||
Deine Feed-URL (wird nur jetzt einmal angezeigt):
|
||||
</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<input readOnly value={url} onFocus={e => e.target.select()}
|
||||
className='flex-1 border border-blue-300 rounded px-2 py-1.5 text-xs font-mono bg-white' />
|
||||
<button onClick={copy}
|
||||
className='px-3 py-1.5 bg-blue-600 text-white rounded text-xs font-medium hover:bg-blue-700 whitespace-nowrap'>
|
||||
{copied ? 'Kopiert ✓' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!url && enabled && (
|
||||
<p className='mb-4 text-sm text-green-700 font-medium'>
|
||||
✓ Feed ist aktiv. Aus Sicherheitsgründen wird die URL nicht erneut angezeigt –
|
||||
bei Verlust neu erzeugen (das alte Token wird dabei ungültig).
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<button onClick={createOrRotate} disabled={busy}
|
||||
className='py-2 px-4 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'>
|
||||
{busy ? '…' : enabled ? 'URL neu erzeugen (rotieren)' : 'Feed aktivieren'}
|
||||
</button>
|
||||
{enabled && (
|
||||
<button onClick={disable} disabled={busy}
|
||||
className='py-2 px-4 border border-red-300 text-red-600 rounded-lg text-sm font-medium hover:bg-red-50 disabled:opacity-50'>
|
||||
Feed deaktivieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hauptseite ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProfilePage() {
|
||||
@@ -313,6 +397,9 @@ export function ProfilePage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Kalender-Abo (iCal) */}
|
||||
<IcalSection />
|
||||
|
||||
{/* Passwort ändern */}
|
||||
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
|
||||
<h2 className='font-semibold text-gray-700 mb-4'>Passwort ändern</h2>
|
||||
|
||||
Reference in New Issue
Block a user