diff --git a/backend/app/main.py b/backend/app/main.py index a53aece..5bc2abe 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,7 @@ from app.routers import special_assignments from app.routers import hours_payouts from app.routers import public_stamp from app.routers import ical +from app.routers import ha from app.routers import reseller, tenants from app.routers import tls_admin @@ -140,6 +141,7 @@ app.include_router(companies.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(ical.router, prefix=API_PREFIX) +app.include_router(ha.router, prefix=API_PREFIX) app.include_router(absences.router, prefix=API_PREFIX) app.include_router(reports.router, prefix=API_PREFIX) app.include_router(ldap.router, prefix=API_PREFIX) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 7f45685..7a63425 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -71,6 +71,8 @@ class User(Base): # (/absences/ical/.ics). Nur eigene Abwesenheiten, kein JWT nötig. ical_token_hash: Mapped[str | None] = mapped_column(Text, unique=True) + ha_token_hash: Mapped[str | None] = mapped_column(Text, unique=True) + # Personalnummer (numerisch, eindeutig pro Firma; bleibt nach Deaktivierung reserviert) personnel_number: Mapped[str | None] = mapped_column(String(50)) diff --git a/backend/app/routers/ha.py b/backend/app/routers/ha.py new file mode 100644 index 0000000..b22e27c --- /dev/null +++ b/backend/app/routers/ha.py @@ -0,0 +1,114 @@ +"""Read-only Arbeitszeit-Status für Home Assistant (REST-Sensor). + +Token-gescoped auf genau einen Nutzer (gehasht in `users.ha_token_hash`), kein JWT, +opt-in, rotierbar. Liefert nur die eigenen Zeitdaten des Token-Inhabers. +Read-only → RLS-Bypass-Kontext (get_db default) bleibt stabil. +""" +from __future__ import annotations + +import hashlib +import secrets +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Response +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.time_entry import TimeEntry +from app.models.user import User +from app.services.time_service import time_service + +router = APIRouter(tags=["Home Assistant"]) + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def _secs(t) -> int: + return t.hour * 3600 + t.minute * 60 + t.second + + +def _elapsed_secs(start, now) -> int: + diff = _secs(now) - _secs(start) + return diff + 86400 if diff < 0 else diff + + +@router.get("/time/ha/{token}") +async def ha_status(token: str, db: AsyncSession = Depends(get_db)) -> dict: + user = await db.scalar(select(User).where(User.ha_token_hash == _hash_token(token))) + if user is None or not user.is_active: + raise HTTPException(status_code=404, detail="Nicht gefunden.") + + now = datetime.now(timezone.utc) + now_t = now.time().replace(microsecond=0, tzinfo=None) + + entries = list( + await db.scalars( + select(TimeEntry) + .where(TimeEntry.user_id == user.id, TimeEntry.date == now.date()) + .order_by(TimeEntry.start_time) + ) + ) + + worked_min = 0 + break_min = 0 + status = "out" + since = None + for e in entries: + break_min += e.break_minutes + if e.end_time is not None: + worked_min += e.worked_minutes or 0 + continue + running = _elapsed_secs(e.start_time, now_t) // 60 + status = "working" + since = datetime.combine(e.date, e.start_time, tzinfo=timezone.utc).isoformat() + if e.break_start is not None: + status = "break" + active_pause = _elapsed_secs(e.break_start, now_t) // 60 + break_min += active_pause + running -= active_pause + worked_min += max(0, running - e.break_minutes) + + balance = await time_service.get_balance(user.id, user, db) + + return { + "status": status, + "stamped_in_since": since, + "worked_today_min": worked_min, + "break_today_min": break_min, + "month_worked_h": balance.total_hours_worked, + "month_expected_h": balance.expected_hours, + "month_balance_h": balance.overtime_hours, + "updated_at": now.isoformat(), + } + + +@router.get("/users/me/ha-token") +async def ha_token_status(current_user: User = Depends(get_current_user)) -> dict: + return {"enabled": current_user.ha_token_hash is not None} + + +@router.post("/users/me/ha-token") +async def create_or_rotate_ha_token( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + token = secrets.token_urlsafe(32) + current_user.ha_token_hash = _hash_token(token) + await db.commit() + base = settings.frontend_url.rstrip("/") + return {"enabled": True, "url": f"{base}/api/v1/time/ha/{token}"} + + +@router.delete("/users/me/ha-token", status_code=204) +async def delete_ha_token( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Response: + current_user.ha_token_hash = None + await db.commit() + return Response(status_code=204) diff --git a/backend/migrations/versions/0048_ha_token.py b/backend/migrations/versions/0048_ha_token.py new file mode 100644 index 0000000..025dba6 --- /dev/null +++ b/backend/migrations/versions/0048_ha_token.py @@ -0,0 +1,29 @@ +"""Home-Assistant-Sensor: users.ha_token_hash + +Revision ID: 0048 +Revises: 0047 +Create Date: 2026-10-28 + +Gehashtes, rotierbares Token für den read-only Arbeitszeit-Status +(/time/ha/). NULL = deaktiviert. Keine RLS-Änderung (nullable Spalte +auf bestehender users-Policy). +""" +from alembic import op + +revision = "0048" +down_revision = "0047" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS ha_token_hash TEXT") + op.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_ha_token_hash " + "ON users (ha_token_hash) WHERE ha_token_hash IS NOT NULL" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_users_ha_token_hash") + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS ha_token_hash") diff --git a/backend/tests/test_ha.py b/backend/tests/test_ha.py new file mode 100644 index 0000000..6bfc8b4 --- /dev/null +++ b/backend/tests/test_ha.py @@ -0,0 +1,61 @@ +"""Tests für den Home-Assistant-Status-Endpunkt (router/ha.py).""" +import pytest +import pytest_asyncio +from httpx import AsyncClient + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def ha_headers(client: AsyncClient): + resp = await client.post("/api/v1/auth/register", json={ + "company_name": "HA GmbH", + "first_name": "Home", + "last_name": "Assistant", + "email": "admin@hagmbh.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_ha_token_disabled_by_default(client: AsyncClient, ha_headers): + r = await client.get("/api/v1/users/me/ha-token", headers=ha_headers) + assert r.status_code == 200 + assert r.json()["enabled"] is False + + +@pytest.mark.asyncio(loop_scope="session") +async def test_ha_status_lifecycle(client: AsyncClient, ha_headers): + r = await client.post("/api/v1/users/me/ha-token", json={}, headers=ha_headers) + assert r.status_code == 200, r.text + path = r.json()["url"].split("/api/v1", 1)[1] + + out = await client.get("/api/v1" + path) + assert out.status_code == 200 + body = out.json() + assert body["status"] == "out" + assert body["worked_today_min"] == 0 + assert body["stamped_in_since"] is None + + s = await client.post("/api/v1/time/stamp-in", json={}, headers=ha_headers) + assert s.status_code == 201, s.text + body = (await client.get("/api/v1" + path)).json() + assert body["status"] == "working" + assert body["stamped_in_since"] is not None + + b = await client.post("/api/v1/time/break-start", json={}, headers=ha_headers) + assert b.status_code == 200, b.text + assert (await client.get("/api/v1" + path)).json()["status"] == "break" + + await client.post("/api/v1/time/break-end", json={}, headers=ha_headers) + await client.post("/api/v1/time/stamp-out", json={}, headers=ha_headers) + assert (await client.get("/api/v1" + path)).json()["status"] == "out" + + r2 = await client.post("/api/v1/users/me/ha-token", json={}, headers=ha_headers) + new_path = r2.json()["url"].split("/api/v1", 1)[1] + assert (await client.get("/api/v1" + path)).status_code == 404 + assert (await client.get("/api/v1" + new_path)).status_code == 200 + + d = await client.delete("/api/v1/users/me/ha-token", headers=ha_headers) + assert d.status_code == 204 + assert (await client.get("/api/v1" + new_path)).status_code == 404 diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 6588d91..6f091b7 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -310,6 +310,90 @@ function IcalSection() { ) } +// ── Home Assistant ──────────────────────────────────────────────────────────── + +function HomeAssistantSection() { + 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/ha-token') + .then(r => setEnabled(r.enabled)).catch(() => {}) + }, []) + + async function createOrRotate() { + setBusy(true) + try { + const r = await api.post<{ enabled: boolean; url: string }>('/users/me/ha-token', {}) + setEnabled(true); setUrl(r.url); setCopied(false) + } finally { setBusy(false) } + } + + async function disable() { + setBusy(true) + try { + await api.del('/users/me/ha-token') + setEnabled(false); setUrl('') + } finally { setBusy(false) } + } + + function copy() { + navigator.clipboard.writeText(url).then(() => { + setCopied(true); setTimeout(() => setCopied(false), 2000) + }) + } + + return ( +
+
+

Home Assistant

+

+ Schreibgeschützter Status deiner Arbeitszeit (JSON) für einen REST-Sensor in + Home Assistant. Die URL enthält ein geheimes Token – nicht weitergeben. +

+
+ + {url && ( +
+

+ Deine Sensor-URL (wird nur jetzt einmal angezeigt): +

+
+ e.target.select()} + className='flex-1 border border-blue-300 rounded px-2 py-1.5 text-xs font-mono bg-white' /> + +
+
+ )} + + {!url && enabled && ( +

+ ✓ Sensor-URL ist aktiv. Aus Sicherheitsgründen wird die URL nicht erneut angezeigt – + bei Verlust neu erzeugen (das alte Token wird dabei ungültig). +

+ )} + +
+ + {enabled && ( + + )} +
+
+ ) +} + // ── Hauptseite ──────────────────────────────────────────────────────────────── export function ProfilePage() { @@ -458,6 +542,7 @@ export function ProfilePage() { {/* Kalender-Abo (iCal) */} + {/* Mobile App */}