feat(ha): read-only Arbeitszeit-Status für Home Assistant (Token-URL)
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
Security Audit / Frontend Build (tsc + vite) (push) Canceled after 0s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-10-28 22:33:54 +01:00
1 parent 2f75ebc5a7
commit ed5c52d8b3
6 files changed
+293

No files matched your search

+2
View File
@@ -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)
+2
View File
@@ -71,6 +71,8 @@ class User(Base):
# (/absences/ical/<token>.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))
+114
View File
@@ -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)
@@ -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/<token>). 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")
+61
View File
@@ -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
+85
View File
@@ -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 (
<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'>Home Assistant</h2>
<p className='text-sm text-gray-400 mt-0.5'>
Schreibgeschützter Status deiner Arbeitszeit (JSON) für einen REST-Sensor in
Home Assistant. 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 Sensor-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'>
✓ Sensor-URL 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)' : 'Sensor-URL 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'>
Deaktivieren
</button>
)}
</div>
</div>
)
}
// ── Hauptseite ────────────────────────────────────────────────────────────────
export function ProfilePage() {
@@ -458,6 +542,7 @@ export function ProfilePage() {
{/* Kalender-Abo (iCal) */}
<IcalSection />
<HomeAssistantSection />
{/* Mobile App */}
<MobileAppSection />