feat: Statischer firmenweiter QR-Code für mobiles Ein-/Ausstempeln

Mitarbeiter scannen einen am Eingang ausgehängten QR-Code mit dem Privat-Handy
(/stamp?t=<token>), melden sich per Personalnummer + PIN an und stempeln ein/aus.

Eigener öffentlicher Endpunkt-Pfad, da der Kiosk-PIN-Login Ed25519-Geräte-
Signaturen verlangt, die ein Privat-Handy nicht hat.

Backend:
- Company.public_stamp_enabled (opt-in, default OFF) + rotierbares
  public_stamp_token_hash (SHA-256) + created_at; Migration 0033
- Router /time/public: company/auth/action (slowapi-Limits, AuditLog)
- kiosk_auth_service.login_pin_public() reused PIN-Lockout, keyed auf
  (public:company_id, personnel_number)
- public_stamp_session_service: 120s Redis-Kurz-Session
- Admin-Token-Endpunkte in companies.py (GET/rotate/DELETE)

Frontend:
- Public-Route /stamp (PublicStampPage)
- Stempel-PIN-Verwaltung in ProfilePage (reused POST /users/{id}/kiosk-pin)
- QR-Generierung/Druck/Toggle in CompanySettingsPage

Sicherheit: schwächer als Kiosk (keine Geräte-Signatur/Nonce/IP-Whitelist),
bewusster BYOD-Komfort-Tradeoff; Schutz über PIN + Lockout + opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 15:58:38 +02:00
parent 03d5fd6e2e
commit cead46c1e1
14 changed files with 1130 additions and 2 deletions
+2
View File
@@ -17,6 +17,7 @@ from app.routers import busylight
from app.routers import audit
from app.routers import special_assignments
from app.routers import hours_payouts
from app.routers import public_stamp
@asynccontextmanager
@@ -85,6 +86,7 @@ app.include_router(auth.router, prefix=API_PREFIX)
app.include_router(users.router, prefix=API_PREFIX)
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(absences.router, prefix=API_PREFIX)
app.include_router(reports.router, prefix=API_PREFIX)
app.include_router(ldap.router, prefix=API_PREFIX)
+7
View File
@@ -44,6 +44,13 @@ class Company(Base):
busylight_pull_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True)
busylight_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Öffentliches QR-Stempeln: statischer firmenweiter QR-Code → /stamp?t=<token>.
# Mitarbeiter scannt mit Privat-Handy, meldet sich per Personalnummer + PIN an.
# Opt-in (default OFF). Token gehasht in DB (SHA-256), Klartext nur beim Rotieren.
public_stamp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
public_stamp_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True)
public_stamp_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Kiosk-Konfiguration
kiosk_require_approval: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
kiosk_track_current_user: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+78 -2
View File
@@ -1,12 +1,17 @@
import hashlib
import secrets
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
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 CurrentUser, require_role
from app.core.dependencies import CurrentUser, get_client_ip, require_role
from app.models import Company
from app.models.audit_log import AuditLog
from app.models.department import Department
from app.models.user import User, UserRole
from app.schemas.company import (
@@ -15,6 +20,8 @@ from app.schemas.company import (
DepartmentCreate,
DepartmentOut,
DepartmentUpdate,
PublicStampTokenRotated,
PublicStampTokenStatus,
)
router = APIRouter(prefix="/companies", tags=["Companies"])
@@ -22,6 +29,10 @@ router = APIRouter(prefix="/companies", tags=["Companies"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def _public_stamp_url(token: str) -> str:
return f"{settings.frontend_url.rstrip('/')}/stamp?t={token}"
@router.get("/me", response_model=CompanyOut)
async def get_my_company(current_user: CurrentUser, db: AsyncSession = Depends(get_db)):
company = await db.get(Company, current_user.company_id)
@@ -46,6 +57,71 @@ async def update_my_company(
return CompanyOut.model_validate(company)
# ── Öffentliches QR-Stempel-Token ────────────────────────────────────────────
@router.get("/me/public-stamp-token", response_model=PublicStampTokenStatus)
async def get_public_stamp_token_status(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
return PublicStampTokenStatus(
enabled=company.public_stamp_enabled,
configured=company.public_stamp_token_hash is not None,
created_at=company.public_stamp_token_created_at,
)
@router.post("/me/public-stamp-token/rotate", response_model=PublicStampTokenRotated)
async def rotate_public_stamp_token(
request: Request,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
token = secrets.token_urlsafe(32)
company.public_stamp_token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
company.public_stamp_token_created_at = datetime.now(timezone.utc)
db.add(AuditLog(
company_id=company.id,
user_id=current_user.id,
action="public_stamp_token_rotated",
entity_type="company",
entity_id=company.id,
ip=get_client_ip(request),
))
await db.commit()
return PublicStampTokenRotated(
token=token,
public_url=_public_stamp_url(token),
created_at=company.public_stamp_token_created_at,
)
@router.delete("/me/public-stamp-token", status_code=204)
async def delete_public_stamp_token(
request: Request,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
if company.public_stamp_token_hash is None:
return
company.public_stamp_token_hash = None
company.public_stamp_token_created_at = None
db.add(AuditLog(
company_id=company.id,
user_id=current_user.id,
action="public_stamp_token_revoked",
entity_type="company",
entity_id=company.id,
ip=get_client_ip(request),
))
await db.commit()
# ── Departments ──────────────────────────────────────────────────────────────
@router.get("/me/departments", response_model=list[DepartmentOut])
+155
View File
@@ -0,0 +1,155 @@
"""Öffentliches QR-Stempeln (statischer firmenweiter QR-Code).
Kein Bearer-Token, KEINE Ed25519-Geräte-Signatur (anders als der Kiosk):
ein privates Handy hat keinen Geräteschlüssel. Stattdessen Identifikation per
Personalnummer + PIN. Härtung: Opt-in (default OFF), PIN-Lockout, IP-Rate-Limit,
gehashtes rotierbares Token, 120s-Session, AuditLog.
⚠ SICHERHEIT: Dieser Pfad ist STRIKT SCHWÄCHER als der Kiosk-Pfad keine
Geräte-Signatur, kein Replay-Nonce, keine IP-Whitelist. Er tauscht Sicherheit
gegen BYOD-Komfort. Pro Firma nur aktivieren wenn wirklich benötigt.
"""
from __future__ import annotations
import hashlib
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_client_ip
from app.core.limiter import limiter
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.time_entry import EntrySource
from app.models.user import User
from app.schemas.public_stamp import (
PublicStampActionRequest,
PublicStampActionResponse,
PublicStampAuthRequest,
PublicStampAuthResponse,
PublicStampCompanyInfo,
)
from app.schemas.time_entry import StampInRequest, TimeEntryOut
from app.services.kiosk_auth_service import kiosk_auth_service, _display_name
from app.services.public_stamp_session_service import (
PUBLIC_STAMP_SESSION_TTL,
public_stamp_session_service,
)
from app.services.time_service import time_service
router = APIRouter(prefix="/time/public", tags=["Public Stamping"])
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def _company_from_token(token: str, db: AsyncSession, *, require_enabled: bool) -> Company:
company = await db.scalar(
select(Company).where(Company.public_stamp_token_hash == _hash_token(token))
)
if company is None:
raise HTTPException(status_code=404, detail="QR-Code ungültig.")
if require_enabled and not company.public_stamp_enabled:
raise HTTPException(status_code=403, detail="QR-Stempeln ist für dieses Unternehmen deaktiviert.")
return company
async def _status(user: User, db: AsyncSession) -> dict:
"""Aktuellen Stempel-Status + heutige Einträge ermitteln."""
open_entry = await time_service._get_open_entry(user.id, db)
today = await time_service.get_today(user, db)
return {
"open": open_entry is not None,
"on_break": open_entry is not None and open_entry.break_start is not None,
"today": [TimeEntryOut.model_validate(e) for e in today],
}
@router.get("/company", response_model=PublicStampCompanyInfo)
@limiter.limit("30/minute")
async def public_company_info(
request: Request,
t: str = Query(..., min_length=8),
db: AsyncSession = Depends(get_db),
):
"""Firmen-Header für die Stempel-Seite. Gibt auch bei deaktiviertem
Feature den Namen zurück, damit die Seite einen Hinweis zeigen kann."""
company = await _company_from_token(t, db, require_enabled=False)
return PublicStampCompanyInfo(company_name=company.name, enabled=company.public_stamp_enabled)
@router.post("/auth", response_model=PublicStampAuthResponse)
@limiter.limit("10/minute")
async def public_auth(
request: Request,
body: PublicStampAuthRequest,
db: AsyncSession = Depends(get_db),
):
"""Personalnummer + PIN → Kurz-Session + aktueller Status."""
company = await _company_from_token(body.token, db, require_enabled=True)
user = await kiosk_auth_service.login_pin_public(
body.personnel_number, body.pin, company.id, db
)
session_token = await public_stamp_session_service.create_session(user.id, company.id)
db.add(AuditLog(
company_id=company.id,
user_id=user.id,
action="public_stamp_login",
entity_type="user",
entity_id=user.id,
ip=get_client_ip(request),
))
await db.commit()
status = await _status(user, db)
return PublicStampAuthResponse(
session_token=session_token,
user_name=_display_name(user),
expires_in_seconds=PUBLIC_STAMP_SESSION_TTL,
**status,
)
@router.post("/action", response_model=PublicStampActionResponse)
@limiter.limit("30/minute")
async def public_action(
request: Request,
body: PublicStampActionRequest,
db: AsyncSession = Depends(get_db),
):
"""Stempel-Aktion über eine gültige Kurz-Session ausführen."""
session = await public_stamp_session_service.require_session(body.session_token)
user = await db.get(User, session["user_id"])
if user is None or not user.is_active or str(user.company_id) != session["company_id"]:
raise HTTPException(status_code=401, detail="Session ungültig.")
warnings: list[str] = []
if body.action == "in":
_, warnings = await time_service.stamp_in(
user, StampInRequest(source=EntrySource.KIOSK, note=body.note), db
)
elif body.action == "out":
_, warnings = await time_service.stamp_out(user, body.note, db)
elif body.action == "break_start":
await time_service.break_start(user, db)
elif body.action == "break_end":
await time_service.break_end(user, db)
db.add(AuditLog(
company_id=user.company_id,
user_id=user.id,
action=f"public_stamp_{body.action}",
entity_type="user",
entity_id=user.id,
ip=get_client_ip(request),
))
await db.commit()
status = await _status(user, db)
return PublicStampActionResponse(warnings=warnings, **status)
+17
View File
@@ -1,4 +1,5 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -45,6 +46,21 @@ class CompanyOut(BaseModel):
kiosk_require_approval: bool = True
kiosk_track_current_user: bool = True
kiosk_heartbeat_interval_sec: int = 30
public_stamp_enabled: bool = False
class PublicStampTokenStatus(BaseModel):
"""Status des öffentlichen QR-Stempel-Tokens (kein Klartext)."""
enabled: bool
configured: bool
created_at: datetime | None = None
class PublicStampTokenRotated(BaseModel):
"""Antwort beim Rotieren enthält Klartext-Token + fertige URL (nur einmalig)."""
token: str = Field(..., description="Klartext-Token, wird nur einmal angezeigt.")
public_url: str
created_at: datetime
class CompanyUpdate(BaseModel):
@@ -65,6 +81,7 @@ class CompanyUpdate(BaseModel):
kiosk_require_approval: bool | None = None
kiosk_track_current_user: bool | None = None
kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120)
public_stamp_enabled: bool | None = None
class DepartmentOut(BaseModel):
+47
View File
@@ -0,0 +1,47 @@
"""Schemas für das öffentliche QR-Stempeln (statischer firmenweiter QR-Code).
Flow: Handy scannt QR (/stamp?t=<token>) → Seite zeigt Firmennamen →
Personalnummer + PIN → Kurz-Session (120s) → Ein-/Ausstempeln.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
from app.schemas.time_entry import TimeEntryOut
class PublicStampCompanyInfo(BaseModel):
"""Header-Info für die Stempel-Seite (Token-Auflösung)."""
company_name: str
enabled: bool
class PublicStampAuthRequest(BaseModel):
token: str = Field(..., min_length=8)
personnel_number: str = Field(..., min_length=1, max_length=50)
pin: str = Field(..., min_length=4, max_length=6, pattern=r"^\d+$")
class PublicStampStatus(BaseModel):
"""Aktueller Stempel-Status des Mitarbeiters."""
open: bool
on_break: bool
today: list[TimeEntryOut] = []
class PublicStampAuthResponse(PublicStampStatus):
session_token: str
user_name: str
expires_in_seconds: int
class PublicStampActionRequest(BaseModel):
session_token: str
action: Literal["in", "out", "break_start", "break_end"]
note: str | None = None
class PublicStampActionResponse(PublicStampStatus):
warnings: list[str] = []
@@ -126,6 +126,58 @@ class KioskAuthService:
)
return user, session_token
async def login_pin_public(
self,
personnel_number: str,
pin: str,
company_id: uuid.UUID,
db: AsyncSession,
) -> User:
"""PIN-Auth für das öffentliche QR-Stempeln (ohne Kiosk-Gerät).
Wiederverwendet den Brute-Force-Lockout, aber keyed auf
(company_id, personnel_number) statt (device_id, ...), da hier kein
Gerät existiert. Erzeugt KEINE Kiosk-Session der Aufrufer legt eine
separate öffentliche Kurz-Session an. Gibt nur den User zurück.
"""
import redis.asyncio as aioredis
from app.core.config import settings
# Lockout-Key-Namespace klar vom Kiosk trennen
lock_id = f"public:{company_id}"
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
try:
await self._check_pin_lockout(lock_id, personnel_number, redis_client)
user = await db.scalar(
select(User).where(
User.company_id == company_id,
User.personnel_number == personnel_number,
User.is_active == True,
)
)
if user is None:
# Fehlversuch auch bei unbekannter Personalnummer (Anti-Enumeration)
await self._record_pin_failure(lock_id, personnel_number, redis_client)
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
if not user.kiosk_pin_hash:
raise HTTPException(
status_code=401,
detail="Kein PIN gesetzt. Bitte im Mitarbeiter-Portal einen Stempel-PIN vergeben.",
)
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
await self._record_pin_failure(lock_id, personnel_number, redis_client)
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
await self._clear_pin_failures(lock_id, personnel_number, redis_client)
finally:
await redis_client.aclose()
return user
async def login_nfc(
self,
nfc_uid: str,
@@ -0,0 +1,77 @@
"""Kurzlebige Redis-Sessions für das öffentliche QR-Stempeln.
Anders als der Kiosk (15 min, vertrauenswürdiges Wandterminal) läuft das
öffentliche Stempeln auf einem privaten Handy über einen ungesicherten
öffentlichen Endpunkt. Deshalb sehr kurze TTL (120s): nach Anmeldung
genügend Zeit zum Ein-/Ausstempeln, danach automatischer Verfall.
Redis ist Pflicht. Bei Redis-Ausfall 503.
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import HTTPException
PUBLIC_STAMP_SESSION_TTL = 120 # 2 Minuten
SESSION_KEY_PREFIX = "public_stamp_session:"
class PublicStampSessionService:
def _get_redis(self):
from app.core.redis import get_redis_client
client = get_redis_client()
if client is None:
raise HTTPException(
status_code=503,
detail="Stempel-Service nicht verfügbar (Redis nicht erreichbar).",
)
return client
async def create_session(self, user_id: uuid.UUID, company_id: uuid.UUID) -> str:
redis = self._get_redis()
session_token = str(uuid.uuid4())
key = SESSION_KEY_PREFIX + session_token
payload = {
"user_id": str(user_id),
"company_id": str(company_id),
"created_at": datetime.now(timezone.utc).isoformat(),
}
try:
redis.setex(key, PUBLIC_STAMP_SESSION_TTL, json.dumps(payload))
except Exception as exc:
raise HTTPException(status_code=503, detail=f"Session konnte nicht erstellt werden: {exc}")
return session_token
async def get_session(self, session_token: str) -> Optional[dict]:
redis = self._get_redis()
try:
data = redis.get(SESSION_KEY_PREFIX + session_token)
except Exception as exc:
raise HTTPException(status_code=503, detail=f"Session-Lookup fehlgeschlagen: {exc}")
if data is None:
return None
return json.loads(data)
async def require_session(self, session_token: str) -> dict:
session = await self.get_session(session_token)
if session is None:
raise HTTPException(
status_code=401,
detail="Stempel-Session abgelaufen. Bitte Personalnummer und PIN erneut eingeben.",
)
return session
async def invalidate_session(self, session_token: str) -> None:
redis = self._get_redis()
try:
redis.delete(SESSION_KEY_PREFIX + session_token)
except Exception:
pass # Best-effort
public_stamp_session_service = PublicStampSessionService()
@@ -0,0 +1,38 @@
"""public stamp token + opt-in flag for static QR stamping
Revision ID: 0033
Revises: 0032
Create Date: 2026-06-02
"""
from alembic import op
import sqlalchemy as sa
revision = '0033'
down_revision = '0032'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'companies',
sa.Column('public_stamp_enabled', sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
'companies',
sa.Column('public_stamp_token_hash', sa.String(length=64), nullable=True),
)
op.add_column(
'companies',
sa.Column('public_stamp_token_created_at', sa.DateTime(timezone=True), nullable=True),
)
op.create_unique_constraint(
'uq_companies_public_stamp_token_hash', 'companies', ['public_stamp_token_hash']
)
def downgrade():
op.drop_constraint('uq_companies_public_stamp_token_hash', 'companies', type_='unique')
op.drop_column('companies', 'public_stamp_token_created_at')
op.drop_column('companies', 'public_stamp_token_hash')
op.drop_column('companies', 'public_stamp_enabled')
+168
View File
@@ -0,0 +1,168 @@
"""Tests für das öffentliche QR-Stempeln (Personalnummer + PIN, statischer QR).
Benötigt Redis (PIN-Lockout + Kurz-Session) läuft auf dem Server.
"""
import pytest
import pytest_asyncio
from httpx import AsyncClient
async def _register(client: AsyncClient, company: str, email: str, pin: str | None,
personnel: str = "0001", enable: bool = True):
"""Registriert Firma+Admin, setzt Personalnr/PIN, aktiviert QR-Stempeln,
rotiert das Token. Gibt dict mit headers/token/personnel/pin zurück."""
reg = await client.post("/api/v1/auth/register", json={
"company_name": company,
"first_name": "QR",
"last_name": "Admin",
"email": email,
"password": "Secret123",
})
assert reg.status_code == 201, reg.text
headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
me = await client.get("/api/v1/auth/me", headers=headers)
user_id = me.json()["id"]
pr = await client.patch(f"/api/v1/users/{user_id}",
json={"personnel_number": personnel}, headers=headers)
assert pr.status_code == 200, pr.text
if pin is not None:
pr = await client.post(f"/api/v1/users/{user_id}/kiosk-pin",
json={"pin": pin}, headers=headers)
assert pr.status_code == 200, pr.text
upd = await client.patch("/api/v1/companies/me",
json={"public_stamp_enabled": enable}, headers=headers)
assert upd.status_code == 200, upd.text
rot = await client.post("/api/v1/companies/me/public-stamp-token/rotate", headers=headers)
assert rot.status_code == 200, rot.text
return {"headers": headers, "user_id": user_id,
"token": rot.json()["token"], "personnel": personnel, "pin": pin}
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def ps(client: AsyncClient):
return await _register(client, "QR Stamp GmbH", "qr@stamp.de", "1234")
# ── Token-Auflösung ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_company_info_resolves(client: AsyncClient, ps):
resp = await client.get(f"/api/v1/time/public/company?t={ps['token']}")
assert resp.status_code == 200
body = resp.json()
assert body["company_name"] == "QR Stamp GmbH"
assert body["enabled"] is True
@pytest.mark.asyncio
async def test_company_info_unknown_token_404(client: AsyncClient):
resp = await client.get("/api/v1/time/public/company?t=totally-invalid-token-xyz")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_rotate_returns_url_once(client: AsyncClient, ps):
resp = await client.post("/api/v1/companies/me/public-stamp-token/rotate", headers=ps["headers"])
assert resp.status_code == 200
body = resp.json()
assert "/stamp?t=" in body["public_url"]
assert len(body["token"]) >= 32
# neues Token → ps['token'] (altes) ungültig
old = await client.get(f"/api/v1/time/public/company?t={ps['token']}")
assert old.status_code == 404
# ps-Fixture aktualisieren, damit Folge-Tests das gültige Token nutzen
ps["token"] = body["token"]
# ── Auth ─────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_auth_success(client: AsyncClient, ps):
resp = await client.post("/api/v1/time/public/auth", json={
"token": ps["token"], "personnel_number": ps["personnel"], "pin": ps["pin"],
})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["session_token"]
assert body["expires_in_seconds"] > 0
assert body["open"] is False
@pytest.mark.asyncio
async def test_auth_wrong_pin_401(client: AsyncClient, ps):
resp = await client.post("/api/v1/time/public/auth", json={
"token": ps["token"], "personnel_number": ps["personnel"], "pin": "9999",
})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_auth_disabled_company_403(client: AsyncClient):
setup = await _register(client, "QR Disabled GmbH", "qr@disabled.de", "1234",
personnel="0002", enable=False)
resp = await client.post("/api/v1/time/public/auth", json={
"token": setup["token"], "personnel_number": "0002", "pin": "1234",
})
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_auth_lockout_after_failures(client: AsyncClient):
"""5 Fehlversuche (auch bei unbekannter Personalnr.) → 429 Lockout."""
setup = await _register(client, "QR Lockout GmbH", "qr@lockout.de", "1234",
personnel="0003")
codes = []
for _ in range(6):
r = await client.post("/api/v1/time/public/auth", json={
"token": setup["token"], "personnel_number": "999999", "pin": "0000",
})
codes.append(r.status_code)
assert 429 in codes # Sperre greift
# ── Stempel-Aktionen ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_stamp_in_and_out(client: AsyncClient, ps):
auth = await client.post("/api/v1/time/public/auth", json={
"token": ps["token"], "personnel_number": ps["personnel"], "pin": ps["pin"],
})
assert auth.status_code == 200, auth.text
session = auth.json()["session_token"]
already_open = auth.json()["open"]
if not already_open:
sin = await client.post("/api/v1/time/public/action",
json={"session_token": session, "action": "in"})
assert sin.status_code == 200, sin.text
assert sin.json()["open"] is True
sout = await client.post("/api/v1/time/public/action",
json={"session_token": session, "action": "out"})
assert sout.status_code == 200, sout.text
assert sout.json()["open"] is False
@pytest.mark.asyncio
async def test_action_invalid_session_401(client: AsyncClient):
resp = await client.post("/api/v1/time/public/action", json={
"session_token": "00000000-0000-0000-0000-000000000000", "action": "in",
})
assert resp.status_code == 401
# ── Token-Delete ─────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_delete_token_invalidates(client: AsyncClient):
setup = await _register(client, "QR Delete GmbH", "qr@delete.de", "1234",
personnel="0004")
dl = await client.delete("/api/v1/companies/me/public-stamp-token", headers=setup["headers"])
assert dl.status_code == 204
resp = await client.get(f"/api/v1/time/public/company?t={setup['token']}")
assert resp.status_code == 404
+2
View File
@@ -24,6 +24,7 @@ import { KioskDevicesPage } from './pages/KioskDevicesPage'
import { AuditLogPage } from './pages/AuditLogPage'
import { KioskSetupPage } from './pages/KioskSetupPage'
import { KioskStampPage } from './pages/KioskStampPage'
import { PublicStampPage } from './pages/PublicStampPage'
import { MobilePage } from './pages/mobile/MobilePage'
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
import { SpecialAssignmentsPage } from './pages/SpecialAssignmentsPage'
@@ -40,6 +41,7 @@ export default function App() {
<Route path='/auth/reset-password' element={<ResetPasswordPage />} />
<Route path='/kiosk/setup' element={<KioskSetupPage />} />
<Route path='/kiosk' element={<KioskStampPage />} />
<Route path='/stamp' element={<PublicStampPage />} />
<Route path='/mobile' element={<MobilePage />} />
<Route path='/mobile/login' element={<MobileLoginPage />} />
<Route element={<ProtectedRoute />}>
+167
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import QRCode from 'qrcode'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
@@ -75,6 +76,13 @@ export function CompanySettingsPage() {
const [blPlaintext, setBlPlaintext] = useState<string | null>(null)
const [blBusy, setBlBusy] = useState(false)
const [blCopied, setBlCopied] = useState(false)
// Öffentliches QR-Stempeln
const [psEnabled, setPsEnabled] = useState(false)
const [psStatus, setPsStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null)
const [psUrl, setPsUrl] = useState<string | null>(null)
const [psQr, setPsQr] = useState<string | null>(null)
const [psBusy, setPsBusy] = useState(false)
const [psCopied, setPsCopied] = useState(false)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -110,8 +118,68 @@ export function CompanySettingsPage() {
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
.then(setBlStatus)
.catch(() => {})
api.get<{ enabled: boolean; configured: boolean; created_at: string | null }>('/companies/me/public-stamp-token')
.then(s => { setPsStatus({ configured: s.configured, created_at: s.created_at }); setPsEnabled(s.enabled) })
.catch(() => {})
}, [])
async function rotatePublicStampToken() {
setPsBusy(true)
setError(null)
try {
const res = await api.post<{ token: string; public_url: string; created_at: string }>(
'/companies/me/public-stamp-token/rotate', {}
)
setPsUrl(res.public_url)
setPsStatus({ configured: true, created_at: res.created_at })
setPsCopied(false)
setPsQr(await QRCode.toDataURL(res.public_url, { width: 320, margin: 2, errorCorrectionLevel: 'H' }))
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Generieren des QR-Tokens')
} finally {
setPsBusy(false)
}
}
async function revokePublicStampToken() {
if (!confirm('QR-Code wirklich deaktivieren? Bestehende ausgedruckte Codes funktionieren danach nicht mehr.')) return
setPsBusy(true)
setError(null)
try {
await api.del('/companies/me/public-stamp-token')
setPsStatus({ configured: false, created_at: null })
setPsUrl(null)
setPsQr(null)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Deaktivieren')
} finally {
setPsBusy(false)
}
}
async function copyPublicStampUrl() {
if (!psUrl) return
try {
await navigator.clipboard.writeText(psUrl)
setPsCopied(true)
setTimeout(() => setPsCopied(false), 2000)
} catch { /* clipboard nicht verfügbar */ }
}
function printPublicStampQr() {
if (!psQr) return
const w = window.open('', '_blank', 'width=600,height=800')
if (!w) return
w.document.write(`<!doctype html><html><head><title>QR-Stempel</title>
<style>body{font-family:sans-serif;text-align:center;padding:40px}
h1{font-size:22px}img{width:340px;height:340px}p{color:#555;font-size:15px}</style></head>
<body><h1>Zeiterfassung ${name || company?.name || ''}</h1>
<img src="${psQr}" alt="QR" />
<p>Mit dem Handy scannen, dann Personalnummer + PIN eingeben und ein-/ausstempeln.</p>
<script>window.onload=function(){window.print()}</script></body></html>`)
w.document.close()
}
async function rotateBusylightToken() {
setBlBusy(true)
setError(null)
@@ -179,6 +247,7 @@ export function CompanySettingsPage() {
kiosk_require_approval: kioskRequireApproval,
kiosk_track_current_user: kioskTrackCurrentUser,
kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec,
public_stamp_enabled: psEnabled,
overtime_cap_hours: overtimeCapEnabled ? overtimeCapHours : null,
overtime_expiry_enabled: overtimeExpiryEnabled,
overtime_expiry_month: overtimeExpiryMonth,
@@ -572,6 +641,104 @@ export function CompanySettingsPage() {
</div>
)}
{/* Öffentliches QR-Stempeln */}
{isAdmin && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div className="flex items-center gap-2">
<span className="text-lg">📲</span>
<h2 className="font-semibold text-gray-700">QR-Stempeln (Handy)</h2>
</div>
<p className="text-xs text-gray-400 -mt-2">
Statischer QR-Code zum Aushängen am Eingang. Mitarbeiter scannen mit dem privaten Handy,
melden sich per Personalnummer + PIN an und stempeln ein/aus.
</p>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
Schwächer als Kiosk-Terminals: keine Geräte-Signatur. Schutz nur über PIN + Sperre nach
Fehlversuchen. Nur aktivieren, wenn benötigt.
</div>
{/* Toggle aktiv */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">QR-Stempeln aktivieren</p>
<p className="text-xs text-gray-500 mt-0.5">Speichern nicht vergessen.</p>
</div>
<button
type="button"
onClick={() => setPsEnabled(v => !v)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none ${
psEnabled ? 'bg-blue-600' : 'bg-gray-300'
}`}
>
<span className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
psEnabled ? 'translate-x-5' : 'translate-x-0'
}`} />
</button>
</div>
<div className="text-sm">
{psStatus?.configured ? (
<div className="flex items-center gap-2 text-green-700">
<span className="inline-block w-2 h-2 rounded-full bg-green-500" />
<span>QR-Code aktiv
{psStatus.created_at && (
<span className="text-gray-400 ml-1">(seit {new Date(psStatus.created_at).toLocaleString('de-DE')})</span>
)}
</span>
</div>
) : (
<div className="flex items-center gap-2 text-gray-500">
<span className="inline-block w-2 h-2 rounded-full bg-gray-300" />
<span>Kein QR-Code generiert</span>
</div>
)}
</div>
{psUrl && (
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4 space-y-3">
<p className="text-xs font-semibold text-amber-800">
QR-Code jetzt drucken/sichern die URL wird nur einmal angezeigt.
</p>
{psQr && (
<div className="flex justify-center">
<img src={psQr} alt="QR-Code" className="w-48 h-48 rounded-lg border border-amber-300 bg-white p-2" />
</div>
)}
<div className="flex items-stretch gap-2">
<input type="text" readOnly value={psUrl}
onFocus={e => e.currentTarget.select()}
className="flex-1 font-mono text-xs px-3 py-2 border border-amber-300 rounded bg-white" />
<button onClick={copyPublicStampUrl}
className="px-3 py-2 bg-amber-600 text-white text-xs font-medium rounded hover:bg-amber-700">
{psCopied ? '✓ Kopiert' : 'Kopieren'}
</button>
</div>
<button onClick={printPublicStampQr}
className="w-full px-3 py-2 bg-gray-800 text-white text-xs font-medium rounded hover:bg-gray-900">
🖨 QR-Code drucken
</button>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<button onClick={rotatePublicStampToken} disabled={psBusy}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50">
{psStatus?.configured ? 'Neuen QR-Code generieren' : 'QR-Code generieren'}
</button>
{psStatus?.configured && (
<button onClick={revokePublicStampToken} disabled={psBusy}
className="px-4 py-2 bg-white border border-red-300 text-red-700 text-sm font-medium rounded-lg hover:bg-red-50 disabled:opacity-50">
Deaktivieren
</button>
)}
{psStatus?.configured && (
<span className="text-xs text-gray-400">Beim Neugenerieren wird der bisherige QR-Code ungültig.</span>
)}
</div>
</div>
)}
{/* Mobile-Einstellungen */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex items-center gap-2">
+55
View File
@@ -178,6 +178,12 @@ export function ProfilePage() {
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
// Stempel-PIN (Kiosk / öffentliches QR-Stempeln)
const [pin, setPin] = useState('')
const [pinConfirm, setPinConfirm] = useState('')
const [pinSaving, setPinSaving] = useState(false)
const [pinSuccess, setPinSuccess] = useState(false)
const [pinError, setPinError] = useState<string | null>(null)
const loadMe = () => {
api.get<UserOut>('/auth/me').then(u => {
@@ -204,6 +210,23 @@ export function ProfilePage() {
}
}
async function savePin(e: React.FormEvent) {
e.preventDefault()
if (!me) return
if (pin !== pinConfirm) { setPinError('PINs stimmen nicht überein'); return }
if (!/^\d{4,6}$/.test(pin)) { setPinError('PIN muss 4 bis 6 Ziffern haben'); return }
setPinSaving(true); setPinError(null); setPinSuccess(false)
try {
await api.post(`/users/${me.id}/kiosk-pin`, { pin })
setPinSuccess(true)
setPin(''); setPinConfirm('')
} catch (e: unknown) {
setPinError(e instanceof Error ? e.message : 'Fehler beim Setzen der PIN')
} finally {
setPinSaving(false)
}
}
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className='max-w-lg mx-auto space-y-6'>
@@ -242,6 +265,38 @@ export function ProfilePage() {
<TotpSection enabled={totpEnabled} onToggle={loadMe} />
</div>
{/* Stempel-PIN */}
<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'>Stempel-PIN</h2>
<p className='text-sm text-gray-400 mt-0.5'>
46 Ziffern · für Kiosk-Terminals und das mobile QR-Stempeln (Personalnummer + PIN).
</p>
</div>
<form onSubmit={savePin} className='space-y-4'>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Neue PIN</label>
<input type='password' inputMode='numeric' value={pin}
onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 6))}
autoComplete='off'
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>PIN bestätigen</label>
<input type='password' inputMode='numeric' value={pinConfirm}
onChange={e => setPinConfirm(e.target.value.replace(/\D/g, '').slice(0, 6))}
autoComplete='off'
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
{pinError && <p className='text-sm text-red-600'>{pinError}</p>}
{pinSuccess && <p className='text-sm text-green-600 font-medium'>PIN erfolgreich gesetzt</p>}
<button type='submit' disabled={pinSaving || !pin || !pinConfirm}
className='w-full py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'>
{pinSaving ? 'Speichern…' : 'PIN speichern'}
</button>
</form>
</div>
{/* 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>
+265
View File
@@ -0,0 +1,265 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
const BASE = '/api/v1'
// Öffentliche Endpunkte: KEIN Bearer-Token, daher nicht der api-Client (der
// hängt Authorization an und triggert Token-Refresh). Schlankes fetch.
async function publicPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
const e = new Error(typeof err.detail === 'string' ? err.detail : res.statusText)
;(e as Error & { status?: number }).status = res.status
throw e
}
return res.json()
}
interface TimeEntry { id: string; start_time: string; end_time: string | null }
interface StampStatus { open: boolean; on_break: boolean; today: TimeEntry[] }
interface AuthResponse extends StampStatus { session_token: string; user_name: string; expires_in_seconds: number }
interface ActionResponse extends StampStatus { warnings: string[] }
function fmtTime(iso: string | null): string {
if (!iso) return ''
return iso.slice(0, 5)
}
export function PublicStampPage() {
const [params] = useSearchParams()
const token = params.get('t') ?? ''
const [companyName, setCompanyName] = useState<string | null>(null)
const [enabled, setEnabled] = useState(true)
const [loadingCompany, setLoadingCompany] = useState(true)
const [personnelNumber, setPersonnelNumber] = useState('')
const [pin, setPin] = useState('')
const [sessionToken, setSessionToken] = useState<string | null>(null)
const [userName, setUserName] = useState('')
const [status, setStatus] = useState<StampStatus | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [warnings, setWarnings] = useState<string[]>([])
const [info, setInfo] = useState<string | null>(null)
useEffect(() => {
if (!token) { setLoadingCompany(false); return }
fetch(`${BASE}/time/public/company?t=${encodeURIComponent(token)}`)
.then(r => r.ok ? r.json() : Promise.reject(new Error('ungültig')))
.then((c: { company_name: string; enabled: boolean }) => {
setCompanyName(c.company_name)
setEnabled(c.enabled)
})
.catch(() => setCompanyName(null))
.finally(() => setLoadingCompany(false))
}, [token])
const resetToLogin = useCallback((msg: string) => {
setSessionToken(null)
setStatus(null)
setPin('')
setInfo(msg)
}, [])
async function authenticate(e: React.FormEvent) {
e.preventDefault()
setBusy(true); setError(null); setWarnings([]); setInfo(null)
try {
const res = await publicPost<AuthResponse>('/time/public/auth', {
token, personnel_number: personnelNumber, pin,
})
setSessionToken(res.session_token)
setUserName(res.user_name)
setStatus({ open: res.open, on_break: res.on_break, today: res.today })
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen')
} finally {
setBusy(false)
}
}
async function doAction(action: 'in' | 'out' | 'break_start' | 'break_end') {
if (!sessionToken) return
setBusy(true); setError(null); setWarnings([])
try {
const res = await publicPost<ActionResponse>('/time/public/action', {
session_token: sessionToken, action,
})
setStatus({ open: res.open, on_break: res.on_break, today: res.today })
if (res.warnings.length) setWarnings(res.warnings)
} catch (err: unknown) {
const status = (err as Error & { status?: number }).status
if (status === 401) {
resetToLogin('Sitzung abgelaufen. Bitte erneut anmelden.')
} else {
setError(err instanceof Error ? err.message : 'Aktion fehlgeschlagen')
}
} finally {
setBusy(false)
}
}
// ── Render-Zustände ─────────────────────────────────────────────────────────
if (loadingCompany) {
return (
<Shell>
<div className='flex flex-col items-center gap-3 py-10'>
<div className='animate-spin rounded-full h-9 w-9 border-4 border-blue-500 border-t-transparent' />
<p className='text-sm text-gray-400'>Wird geladen</p>
</div>
</Shell>
)
}
if (!token || companyName === null) {
return (
<Shell>
<div className='bg-red-50 border border-red-200 rounded-xl px-4 py-5 text-center'>
<p className='text-3xl mb-2'>🚫</p>
<p className='font-semibold text-red-700'>QR-Code ungültig</p>
<p className='text-sm text-red-600 mt-1'>Dieser QR-Code ist nicht (mehr) gültig. Bitte an die Verwaltung wenden.</p>
</div>
</Shell>
)
}
if (!enabled) {
return (
<Shell company={companyName}>
<div className='bg-amber-50 border border-amber-200 rounded-xl px-4 py-5 text-center'>
<p className='text-3xl mb-2'>🔒</p>
<p className='font-semibold text-amber-800'>QR-Stempeln deaktiviert</p>
<p className='text-sm text-amber-700 mt-1'>Das mobile Stempeln per QR ist für dieses Unternehmen derzeit nicht aktiviert.</p>
</div>
</Shell>
)
}
// Angemeldet → Stempel-Ansicht
if (sessionToken && status) {
const isOpen = status.open
const onBreak = status.on_break
return (
<Shell company={companyName}>
<div className='space-y-4'>
<p className='text-center text-sm text-gray-500'>Angemeldet als</p>
<p className='text-center text-xl font-bold text-gray-900 -mt-3'>{userName}</p>
{warnings.length > 0 && (
<div className='bg-yellow-50 border border-yellow-200 rounded-xl px-4 py-3'>
<ul className='text-sm text-yellow-700 list-disc list-inside space-y-0.5'>
{warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
{error && <div className='bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700'>{error}</div>}
<div className={`inline-flex w-full justify-center items-center gap-2 px-3 py-2 rounded-full text-sm font-semibold ${
onBreak ? 'bg-yellow-100 text-yellow-700' : isOpen ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
}`}>
<span className={`w-2 h-2 rounded-full ${onBreak ? 'bg-yellow-400' : isOpen ? 'bg-green-500' : 'bg-gray-400'}`} />
{onBreak ? 'In Pause' : isOpen ? 'Eingestempelt' : 'Nicht eingestempelt'}
</div>
{!isOpen ? (
<button onClick={() => doAction('in')} disabled={busy}
className='w-full min-h-[80px] rounded-3xl bg-green-500 active:bg-green-700 text-white text-2xl font-bold shadow-md disabled:opacity-50'>
{busy ? '…' : 'EINSTEMPELN'}
</button>
) : onBreak ? (
<button onClick={() => doAction('break_end')} disabled={busy}
className='w-full min-h-[80px] rounded-3xl bg-yellow-400 active:bg-yellow-600 text-white text-2xl font-bold shadow-md disabled:opacity-50'>
{busy ? '…' : 'PAUSE BEENDEN'}
</button>
) : (
<>
<button onClick={() => doAction('out')} disabled={busy}
className='w-full min-h-[80px] rounded-3xl bg-red-500 active:bg-red-700 text-white text-2xl font-bold shadow-md disabled:opacity-50'>
{busy ? '…' : 'AUSSTEMPELN'}
</button>
<button onClick={() => doAction('break_start')} disabled={busy}
className='w-full min-h-[48px] rounded-xl border border-yellow-300 text-yellow-600 font-semibold active:bg-yellow-50 disabled:opacity-50'>
Pause starten
</button>
</>
)}
{status.today.length > 0 && (
<div className='bg-white rounded-xl border border-gray-200 px-4 py-3'>
<p className='text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2'>Heute</p>
<ul className='text-sm text-gray-700 space-y-1'>
{status.today.map(e => (
<li key={e.id} className='flex justify-between'>
<span>{fmtTime(e.start_time)} {fmtTime(e.end_time)}</span>
</li>
))}
</ul>
</div>
)}
<button onClick={() => resetToLogin('')}
className='w-full text-sm text-gray-400 underline pt-2'>
Fertig / Abmelden
</button>
</div>
</Shell>
)
}
// PIN-Anmeldung
return (
<Shell company={companyName}>
<form onSubmit={authenticate} className='space-y-4'>
{info && <div className='bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-700'>{info}</div>}
{error && <div className='bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700'>{error}</div>}
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Personalnummer</label>
<input
inputMode='numeric' autoComplete='off' value={personnelNumber}
onChange={e => setPersonnelNumber(e.target.value.replace(/\D/g, ''))}
className='w-full text-center text-2xl tracking-widest font-mono border border-gray-300 rounded-xl px-3 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500'
required
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>PIN</label>
<input
type='password' inputMode='numeric' autoComplete='off' value={pin}
onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 6))}
className='w-full text-center text-2xl tracking-widest font-mono border border-gray-300 rounded-xl px-3 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500'
required
/>
<p className='mt-1 text-xs text-gray-400'>PIN im Mitarbeiter-Portal unter Mein Profil setzen/ändern.</p>
</div>
<button type='submit' disabled={busy || !personnelNumber || pin.length < 4}
className='w-full min-h-[56px] rounded-2xl bg-blue-600 active:bg-blue-800 text-white text-lg font-bold shadow-md disabled:opacity-50'>
{busy ? 'Anmelden…' : 'Anmelden'}
</button>
</form>
</Shell>
)
}
function Shell({ company, children }: { company?: string; children: React.ReactNode }) {
return (
<div className='min-h-screen bg-gray-50 flex flex-col items-center px-4 py-8'>
<div className='w-full max-w-sm'>
<div className='text-center mb-6'>
<p className='text-xs font-semibold text-blue-600 uppercase tracking-widest'>Zeiterfassung</p>
<h1 className='text-2xl font-bold text-gray-900 mt-1'>{company ?? 'Stempeln'}</h1>
</div>
{children}
</div>
</div>
)
}