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:
@@ -18,6 +18,7 @@ from app.routers import audit
|
|||||||
from app.routers import special_assignments
|
from app.routers import special_assignments
|
||||||
from app.routers import hours_payouts
|
from app.routers import hours_payouts
|
||||||
from app.routers import public_stamp
|
from app.routers import public_stamp
|
||||||
|
from app.routers import ical
|
||||||
from app.routers import reseller, tenants
|
from app.routers import reseller, tenants
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ app.include_router(users.router, prefix=API_PREFIX)
|
|||||||
app.include_router(companies.router, prefix=API_PREFIX)
|
app.include_router(companies.router, prefix=API_PREFIX)
|
||||||
app.include_router(time_entries.router, prefix=API_PREFIX)
|
app.include_router(time_entries.router, prefix=API_PREFIX)
|
||||||
app.include_router(public_stamp.router, prefix=API_PREFIX)
|
app.include_router(public_stamp.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(ical.router, prefix=API_PREFIX)
|
||||||
app.include_router(absences.router, prefix=API_PREFIX)
|
app.include_router(absences.router, prefix=API_PREFIX)
|
||||||
app.include_router(reports.router, prefix=API_PREFIX)
|
app.include_router(reports.router, prefix=API_PREFIX)
|
||||||
app.include_router(ldap.router, prefix=API_PREFIX)
|
app.include_router(ldap.router, prefix=API_PREFIX)
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ class User(Base):
|
|||||||
# Kalender-Kürzel (vom Manager setzbar, für CalDAV-Template $kuerzel)
|
# Kalender-Kürzel (vom Manager setzbar, für CalDAV-Template $kuerzel)
|
||||||
kuerzel: Mapped[str | None] = mapped_column(String(20))
|
kuerzel: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
|
||||||
|
# iCal-Abo: gehashtes rotierbares Token für den read-only Kalender-Feed
|
||||||
|
# (/absences/ical/<token>.ics). Nur eigene Abwesenheiten, kein JWT nötig.
|
||||||
|
ical_token_hash: Mapped[str | None] = mapped_column(Text, unique=True)
|
||||||
|
|
||||||
# Personalnummer (numerisch, eindeutig pro Firma; bleibt nach Deaktivierung reserviert)
|
# Personalnummer (numerisch, eindeutig pro Firma; bleibt nach Deaktivierung reserviert)
|
||||||
personnel_number: Mapped[str | None] = mapped_column(String(50))
|
personnel_number: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""iCal-Abo-Feed pro Nutzer (read-only, abonnierbar in Outlook/Apple/Google).
|
||||||
|
|
||||||
|
Anders als der CalDAV-Client (der Events aktiv nach Nextcloud *pusht*), stellt
|
||||||
|
dieser Endpunkt einen abonnierbaren `text/calendar`-Feed bereit: der Kalender
|
||||||
|
des Nutzers pollt die URL selbst.
|
||||||
|
|
||||||
|
Sicherheit / DSGVO:
|
||||||
|
- Token-gescoped auf GENAU EINEN Nutzer (gehasht in `users.ical_token_hash`).
|
||||||
|
- Feed enthält ausschließlich die eigenen Abwesenheiten dieses Nutzers.
|
||||||
|
- Kein JWT (Kalender-Apps können kein Bearer) → Zugang allein über das
|
||||||
|
geheime, rotierbare Token in der URL. Opt-in: ohne Token kein Feed.
|
||||||
|
- Read-only, kein DB-Write → RLS-Bypass-Kontext (get_db default) bleibt stabil.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
from icalendar import Calendar, Event
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.models.absence import Absence, AbsenceStatus
|
||||||
|
from app.models.absence_type import AbsenceType
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(tags=["iCal"])
|
||||||
|
|
||||||
|
# Nur bestätigte / laufende Anträge im Kalender zeigen – keine offenen/abgelehnten.
|
||||||
|
_FEED_STATUSES = (
|
||||||
|
AbsenceStatus.APPROVED,
|
||||||
|
AbsenceStatus.FIRST_APPROVED,
|
||||||
|
AbsenceStatus.CANCELLATION_REQUESTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_url(token: str) -> str:
|
||||||
|
base = settings.frontend_url.rstrip("/")
|
||||||
|
return f"{base}/api/v1/absences/ical/{token}.ics"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Öffentlicher Feed (kein JWT) ──────────────────────────────────────────────
|
||||||
|
@router.get("/absences/ical/{token}.ics")
|
||||||
|
async def ical_feed(token: str, db: AsyncSession = Depends(get_db)) -> Response:
|
||||||
|
user = await db.scalar(select(User).where(User.ical_token_hash == _hash_token(token)))
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(status_code=404, detail="Kalender-Feed nicht gefunden.")
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(Absence, AbsenceType.name, AbsenceType.color)
|
||||||
|
.join(AbsenceType, AbsenceType.id == Absence.type_id)
|
||||||
|
.where(
|
||||||
|
Absence.user_id == user.id,
|
||||||
|
Absence.status.in_(_FEED_STATUSES),
|
||||||
|
)
|
||||||
|
.order_by(Absence.start_date)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
cal = Calendar()
|
||||||
|
cal.add("prodid", "-//TimeMaster//iCal Feed//DE")
|
||||||
|
cal.add("version", "2.0")
|
||||||
|
cal.add("x-wr-calname", f"TimeMaster – {user.first_name} {user.last_name}")
|
||||||
|
cal.add("x-published-ttl", "PT12H")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
for absence, type_name, _color in rows:
|
||||||
|
summary = type_name
|
||||||
|
if absence.status == AbsenceStatus.FIRST_APPROVED:
|
||||||
|
summary = f"{type_name} (vorläufig)"
|
||||||
|
elif absence.status == AbsenceStatus.CANCELLATION_REQUESTED:
|
||||||
|
summary = f"{type_name} (Storno beantragt)"
|
||||||
|
if absence.half_day_start or absence.half_day_end:
|
||||||
|
summary += " ½"
|
||||||
|
|
||||||
|
ev = Event()
|
||||||
|
ev.add("uid", f"absence-{absence.id}@timemaster")
|
||||||
|
ev.add("dtstart", absence.start_date)
|
||||||
|
ev.add("dtend", absence.end_date + timedelta(days=1)) # DTEND exklusiv
|
||||||
|
ev.add("summary", summary)
|
||||||
|
if absence.note:
|
||||||
|
ev.add("description", absence.note)
|
||||||
|
ev.add("status", "CONFIRMED")
|
||||||
|
ev.add("transp", "TRANSPARENT")
|
||||||
|
ev.add("dtstamp", now)
|
||||||
|
ev.add("last-modified", absence.created_at or now)
|
||||||
|
cal.add_component(ev)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=cal.to_ical(),
|
||||||
|
media_type="text/calendar; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": 'inline; filename="timemaster.ics"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Token-Verwaltung (authentifiziert) ────────────────────────────────────────
|
||||||
|
@router.get("/users/me/ical-token")
|
||||||
|
async def ical_token_status(current_user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Ist der Feed aktiviert? Das Token selbst wird nie zurückgegeben (nur Hash gespeichert)."""
|
||||||
|
return {"enabled": current_user.ical_token_hash is not None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/me/ical-token")
|
||||||
|
async def create_or_rotate_ical_token(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Erzeugt ein neues Token (rotiert ein bestehendes) und liefert die Feed-URL einmalig zurück."""
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
current_user.ical_token_hash = _hash_token(token)
|
||||||
|
await db.commit()
|
||||||
|
return {"enabled": True, "url": _feed_url(token)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/me/ical-token", status_code=204)
|
||||||
|
async def delete_ical_token(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> Response:
|
||||||
|
current_user.ical_token_hash = None
|
||||||
|
await db.commit()
|
||||||
|
return Response(status_code=204)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""iCal-Abo: users.ical_token_hash
|
||||||
|
|
||||||
|
Revision ID: 0041
|
||||||
|
Revises: 0040
|
||||||
|
Create Date: 2026-07-08
|
||||||
|
|
||||||
|
Gehashtes, rotierbares Token für den read-only iCal-Kalender-Feed pro Nutzer
|
||||||
|
(/absences/ical/<token>.ics). NULL = Feed deaktiviert. Keine RLS-Änderung
|
||||||
|
(nur neue nullable Spalte auf bestehender users-Policy).
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0041"
|
||||||
|
down_revision = "0040"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS ical_token_hash TEXT")
|
||||||
|
op.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_ical_token_hash "
|
||||||
|
"ON users (ical_token_hash) WHERE ical_token_hash IS NOT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_users_ical_token_hash")
|
||||||
|
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS ical_token_hash")
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Tests für den iCal-Abo-Feed (router/ical.py)."""
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||||
|
async def ical_headers(client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/register", json={
|
||||||
|
"company_name": "iCal GmbH",
|
||||||
|
"first_name": "Cal",
|
||||||
|
"last_name": "Endar",
|
||||||
|
"email": "admin@icalgmbh.de",
|
||||||
|
"password": "Secret123",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_ical_token_disabled_by_default(client: AsyncClient, ical_headers):
|
||||||
|
r = await client.get("/api/v1/users/me/ical-token", headers=ical_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_ical_feed_lifecycle(client: AsyncClient, ical_headers):
|
||||||
|
# Aktivieren → URL zurück
|
||||||
|
r = await client.post("/api/v1/users/me/ical-token", json={}, headers=ical_headers)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
url = r.json()["url"]
|
||||||
|
assert url.endswith(".ics")
|
||||||
|
assert r.json()["enabled"] is True
|
||||||
|
|
||||||
|
# Öffentlicher Feed OHNE Auth erreichbar, liefert VCALENDAR
|
||||||
|
path = url.split("/api/v1", 1)[1]
|
||||||
|
feed = await client.get("/api/v1" + path) # kein Authorization-Header
|
||||||
|
assert feed.status_code == 200
|
||||||
|
assert "text/calendar" in feed.headers["content-type"]
|
||||||
|
assert feed.text.startswith("BEGIN:VCALENDAR")
|
||||||
|
|
||||||
|
# Status jetzt aktiv
|
||||||
|
st = await client.get("/api/v1/users/me/ical-token", headers=ical_headers)
|
||||||
|
assert st.json()["enabled"] is True
|
||||||
|
|
||||||
|
# Rotieren → altes Token wird ungültig
|
||||||
|
old_path = path
|
||||||
|
r2 = await client.post("/api/v1/users/me/ical-token", json={}, headers=ical_headers)
|
||||||
|
new_path = r2.json()["url"].split("/api/v1", 1)[1]
|
||||||
|
assert new_path != old_path
|
||||||
|
assert (await client.get("/api/v1" + old_path)).status_code == 404
|
||||||
|
assert (await client.get("/api/v1" + new_path)).status_code == 200
|
||||||
|
|
||||||
|
# Deaktivieren → Feed weg
|
||||||
|
d = await client.delete("/api/v1/users/me/ical-token", headers=ical_headers)
|
||||||
|
assert d.status_code == 204
|
||||||
|
assert (await client.get("/api/v1" + new_path)).status_code == 404
|
||||||
|
st2 = await client.get("/api/v1/users/me/ical-token", headers=ical_headers)
|
||||||
|
assert st2.json()["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_ical_feed_bad_token(client: AsyncClient):
|
||||||
|
r = await client.get("/api/v1/absences/ical/nonexistenttoken.ics")
|
||||||
|
assert r.status_code == 404
|
||||||
@@ -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 ────────────────────────────────────────────────────────────────
|
// ── Hauptseite ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
@@ -313,6 +397,9 @@ export function ProfilePage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Kalender-Abo (iCal) */}
|
||||||
|
<IcalSection />
|
||||||
|
|
||||||
{/* Passwort ändern */}
|
{/* Passwort ändern */}
|
||||||
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
|
<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>
|
<h2 className='font-semibold text-gray-700 mb-4'>Passwort ändern</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user