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:
2026-07-08 16:12:05 +02:00
co-authored by Claude Opus 4.8
parent d27be740a0
commit d13350b38b
6 changed files with 320 additions and 0 deletions
+87
View File
@@ -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>