diff --git a/.gitignore b/.gitignore index 5d2a3df..8298fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,36 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + node_modules dist -*.log +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea .DS_Store -.env -.env.local +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Projekt-spezifisch: Secrets und lokale Artefakte +mail-server/mail.env +mail-url.txt +mail-vorschau*.html +tmp-* +.chrome-tmp*/ +mp-dist.tar.gz +shots/ +*.bak-v* +.vite +_ref/ diff --git a/README.md b/README.md index b67f91a..15b7c59 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Hocks Mieterportal -Mieterportal für die Verwaltung von 50 Wohnungen in 3 Häusern — analog zum Schulportal der Hocks-Gruppe. +Mieterportal der Hocks Verwaltung für **50 Wohnungen in 3 Häusern** (Hauptstraße 12 · +Ringweg 3 · Am Park 5) – analog zum [Schulportal](https://git.hocks.eu/ah-admin/Schulportal). +Live-Ziel: `https://mieterportal.hocks.eu`. ## Module @@ -10,26 +12,35 @@ Mieterportal für die Verwaltung von 50 Wohnungen in 3 Häusern — analog zum S 4. **Dokumente** – Verträge, Abrechnungen, Hausordnung; zentraler Such- und Filterzugriff 5. **Nachrichten** – Direktkommunikation Mieter ↔ Verwaltung -Der Rollenwechsel Mieter ↔ Verwaltung erfolgt über die Schaltfläche in der Seitenleiste. Alle Demo-Daten werden lokal im Browser (localStorage) gespeichert. +Der Rollenwechsel Mieter ↔ Verwaltung erfolgt über die Seitenleiste (Demo-Modus). +Alle Demo-Daten werden lokal im Browser (localStorage) gespeichert. Dark-Mode-Umschalter +in der Seitenleiste bzw. Mobilen Kopfzeile. -## Lokale Entwicklung +## Entwicklung ```bash npm install npm run dev # http://localhost:3000 npm run build # Produktionsbuild nach dist/ +python beispieldaten.py # generiert src/data/generated.json (Seed wählbar) ``` -## Docker +Projekt-Kontext für Kimi Work: [kimi.md](kimi.md). + +## Deployment (bewährter Workflow, s. kimi.md Kap. 4) ```bash -docker build -t mieterportal . -docker run -d -p 8080:80 --name mieterportal mieterportal -# oder: -docker compose up -d --build +./deploy.sh ``` -Der Container liefert das gebaute Frontend über Nginx (SPA-Routing inklusive). -Für den Betrieb unter `https://mieterportal.hocks.eu` einfach einen Reverse Proxy -(Traefik, nginx, Caddy) davor setzen — Beispiel-Labels für Traefik inkl. Let's Encrypt -liegen in der `docker-compose.yml`. +baut das Frontend, lädt das `dist/`-Paket per `pscp` auf den Server (Port 38592, +Pageant-Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk`) und startet den nginx-Container +`mp-portal` neu. TLS/Subdomain liefert der Nginx Proxy Manager (npm_default-Netz). + +Alternativ lokal ohne Server: `docker build -t mieterportal . && docker run -p 8080:80 mieterportal`. + +## Optional: Mail-Stack + +`mail-server/` enthält den aus dem Schulportal übernommenen Aufbau: Postfix-Relay +(nur intern, kein offenes Relay) + schlanke Python-API. Konfiguration über +`mail-server/mail.env` (Vorlage: `mail.env.example`), **nicht** im Repo. diff --git a/__pycache__/beispieldaten.cpython-312.pyc b/__pycache__/beispieldaten.cpython-312.pyc new file mode 100644 index 0000000..4e832a2 Binary files /dev/null and b/__pycache__/beispieldaten.cpython-312.pyc differ diff --git a/beispieldaten.py b/beispieldaten.py new file mode 100644 index 0000000..39e4262 --- /dev/null +++ b/beispieldaten.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Hocks Mieterportal – Beispieldaten-Generator. + +Erzeugt deterministisch die Demo-Daten des Portals und schreibt sie nach +``src/data/generated.json`` (Units + Transaktionen). Die handschriftlichen +Inhalte (Tickets, Dokumente, Nachrichten, Ankündigungen) liegen weiterhin in +``src/data/seed.ts``. + +Aufruf: python beispieldaten.py (Standard-Seed 20261002) + python beispieldaten.py 42 (eigener Seed zum Experimentieren) + +Achtung: Der Demo-Mieter „Anna Berger" (Whg. Hauptstraße 12 Nr. 7) und die +Algorithmen sind synchron zu src/data/seed.ts zu halten. +""" + +import json +import sys +from pathlib import Path + +SEED = int(sys.argv[1]) if len(sys.argv) > 1 else 20261002 + +BUILDINGS = [ + {"name": "Hauptstraße 12", "prefix": "HS", "count": 20, "baseRent": 9.8}, + {"name": "Ringweg 3", "prefix": "RW", "count": 15, "baseRent": 8.9}, + {"name": "Am Park 5", "prefix": "AP", "count": 15, "baseRent": 10.4}, +] + +FIRST_NAMES = [ + "Anna", "Jonas", "Marta", "Leon", "Sofia", "Felix", "Lena", "Paul", + "Emma", "Noah", "Clara", "Ben", "Nora", "Max", "Julia", "Tim", + "Sarah", "Finn", "Laura", "Jan", "Mia", "Tom", "Ela", "Kim", +] +LAST_NAMES = [ + "Berger", "Klein", "Wolf", "Neumann", "Schäfer", "Krüger", "Brandt", + "Vogel", "Otto", "Horn", "Busch", "Thomas", "Pohl", "Sander", "Lindner", +] + +DEMO_TENANT = "Anna Berger" +DEMO_UNIT_ID = "hs-7" + +MONTHS = [ + "Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember", +] + + +def gen_units() -> list[dict]: + units: list[dict] = [] + name_idx = 0 + + def next_name() -> str: + nonlocal name_idx + n = f"{FIRST_NAMES[name_idx % len(FIRST_NAMES)]} {LAST_NAMES[name_idx % len(LAST_NAMES)]}" + name_idx += 1 + return n + + for b in BUILDINGS: + for i in range(b["count"]): + num = i + 1 + size = 38 + ((num * 7 + len(b["prefix"]) * 3) % 78) # 38–115 m² + cold = round(size * b["baseRent"]) + warm = cold + round(2.4 * size) + (35 if num % 3 == 0 else 0) + floor = 0 if num <= 2 else min(4, (num - 1) // 4) + occupied = num % 9 != 0 # ~89 % Vermietung + if b["prefix"] == "HS" and num == 7: + tenant: str | None = DEMO_TENANT + elif occupied: + tenant = next_name() + else: + tenant = None + units.append({ + "id": f"{b['prefix'].lower()}-{num}", + "building": b["name"], + "number": str(num), + "floor": floor, + "size": size, + "coldRent": cold, + "warmRent": warm, + "tenant": tenant, + "status": "vermietet" if occupied else "frei", + }) + return units + + +def gen_transactions(units: list[dict]) -> list[dict]: + txs: list[dict] = [] + for u in units: + if u["status"] != "vermietet": + continue + for m in range(12): + due = m + 1 + if due <= 9: + status = "überfällig" if (ord(u["id"][3]) + m) % 11 == 0 else "bezahlt" + else: + status = "offen" + txs.append({ + "id": f"tx-{u['id']}-{m}", + "unitId": u["id"], + "tenant": u["tenant"], + "month": MONTHS[m], + "year": 2026, + "amount": u["warmRent"], + "type": "Miete", + "status": status, + **({"paidAt": f"0{(m % 8) + 1}.0{due}.2026"} if status == "bezahlt" else {}), + }) + return txs + + +def main() -> None: + units = gen_units() + data = { + "meta": {"seed": SEED, "units": len(units), + "rented": sum(1 for u in units if u["status"] == "vermietet")}, + "units": units, + "transactions": gen_transactions(units), + } + out = Path(__file__).parent / "src" / "data" / "generated.json" + out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"{out} geschrieben: {data['meta']['units']} Wohnungen " + f"({data['meta']['rented']} vermietet), {len(data['transactions'])} Buchungen") + + +if __name__ == "__main__": + main() diff --git a/deploy.sh b/deploy.sh index f4afb27..c422754 100644 --- a/deploy.sh +++ b/deploy.sh @@ -1,43 +1,46 @@ #!/usr/bin/env bash -# Hocks Mieterportal – Deployment auf hocks.eu +# Hocks Mieterportal – Deploy auf hocks.eu (Workflow aus dem Schulportal) # -# SSH-Auth: Pageant (plink) oder OpenSSH-Schlüssel ~/.ssh/hocks_deploy. +# 1. npm run build 2. Paket (dist + compose + nginx) per pscp hochladen +# 3. Entpacken nach /opt/mieterportal, Container mp-portal neu starten +# +# Voraussetzungen: Pageant mit priv_ionos_hocks_vps.ppk läuft. # Aufruf: ./deploy.sh set -euo pipefail -SERVER="${SERVER:-hocks.eu}" -SSH_PORT="${SSH_PORT:-38592}" -SSH_USER="${SSH_USER:-root}" +SERVER="root@hocks.eu" +PORT=38592 +HOSTKEY="SHA256:4G3UxsugNkHH6BrPXScLXh+vX8P1sLgWKn8cZBKSRxw" +KEY="Z:\Devel\ssh\priv_ionos_hocks_vps.ppk" +PSCP="/c/Program Files/PuTTY/pscp.exe" +PLINK="/c/Program Files/PuTTY/plink.exe" REMOTE_DIR="/opt/mieterportal" -REPO="https://git.hocks.eu/ah-admin/Mieterportal.git" -IMAGE="mieterportal:latest" -run() { # run - local cmd="$1" - if command -v plink >/dev/null 2>&1 || [ -f "/c/Program Files/PuTTY/plink.exe" ]; then - local PLINK="${PLINK:-$(command -v plink || echo '/c/Program Files/PuTTY/plink.exe')}" - "$PLINK" -ssh -P "$SSH_PORT" -agent -batch "${SSH_USER}@${SERVER}" "$cmd" - else - ssh -p "$SSH_PORT" -i ~/.ssh/hocks_deploy -o BatchMode=yes \ - -o StrictHostKeyChecking=accept-new "${SSH_USER}@${SERVER}" "$cmd" - fi -} +echo "==> 1/4 Frontend bauen" +npm run build -echo "==> Verbindung zu ${SSH_USER}@${SERVER}:${SSH_PORT} testen" -run "docker --version" +echo "==> 2/4 Paket erstellen und hochladen" +rm -rf tmp-deploy && mkdir -p tmp-deploy/docker +cp -r dist tmp-deploy/dist +cp docker-compose.yml tmp-deploy/ +cp docker/nginx.conf tmp-deploy/docker/ +tar -czf mp-dist.tar.gz -C tmp-deploy . +rm -rf tmp-deploy +"$PSCP" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" \ + mp-dist.tar.gz "$SERVER":/tmp/mp-dist.tar.gz +rm -f mp-dist.tar.gz -echo "==> Repository auf dem Server aktualisieren (${REMOTE_DIR})" -run "mkdir -p ${REMOTE_DIR} && cd ${REMOTE_DIR} && \ - (git rev-parse --git-dir >/dev/null 2>&1 || git clone ${REPO} .) && \ - git fetch origin && git reset --hard origin/main" +echo "==> 3/4 Auf Server entpacken und Container ersetzen" +"$PLINK" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" "$SERVER" " + mkdir -p $REMOTE_DIR && + tar -xzf /tmp/mp-dist.tar.gz -C $REMOTE_DIR && + rm -f /tmp/mp-dist.tar.gz && + cd $REMOTE_DIR && docker compose up -d +" -echo "==> Docker-Image bauen" -run "cd ${REMOTE_DIR} && docker build -t ${IMAGE} ." - -echo "==> Container ersetzen" -run "docker rm -f mieterportal 2>/dev/null || true" -run "cd ${REMOTE_DIR} && docker compose up -d" - -echo "==> Status" -run "docker ps --filter name=mieterportal --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'" -echo "Fertig. Portal läuft auf dem Server (Routing: mieterportal.hocks.eu)." +echo "==> 4/4 Status prüfen" +"$PLINK" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" "$SERVER" " + docker ps --filter name=mp-portal --format 'table {{.Names}}\t{{.Status}}' && + curl -s https://mieterportal.hocks.eu/ | grep -o 'index-[^\"]*\.js' | head -1 +" +echo "Fertig: https://mieterportal.hocks.eu" diff --git a/docker-compose.yml b/docker-compose.yml index e26f878..c8f71c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,18 @@ +# Hocks Mieterportal – Portal-Container (statisches Frontend hinter NPM) +# TLS + Subdomain liefert der Nginx Proxy Manager (npm_default-Netz). +# Deploy: ./deploy.sh baut dist/ lokal und lädt es auf den Server. + services: - mieterportal: - build: . - image: mieterportal:latest - container_name: mieterportal + mp-portal: + image: nginx:1.27-alpine + container_name: mp-portal restart: unless-stopped - ports: - - "8080:80" - labels: - # Beispiel für Traefik – https://mieterportal.hocks.eu mit TLS - - "traefik.enable=true" - - "traefik.http.routers.mieterportal.rule=Host(`mieterportal.hocks.eu`)" - - "traefik.http.routers.mieterportal.entrypoints=websecure" - - "traefik.http.routers.mieterportal.tls.certresolver=letsencrypt" - - "traefik.http.services.mieterportal.loadbalancer.server.port=80" + volumes: + - ./dist:/usr/share/nginx/html:ro + - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - npm_default + +networks: + npm_default: + external: true diff --git a/docker/nginx.conf b/docker/nginx.conf index f8530a1..503377b 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -9,6 +9,11 @@ server { gzip_types text/css application/javascript image/svg+xml application/json; gzip_min_length 1024; + # HTML nie cachen (Assets sind hash-benannt → ein Reload genügt) + location = /index.html { + add_header Cache-Control "no-cache" always; + } + # Cache für gehashte Build-Assets location /assets/ { expires 30d; diff --git a/kimi.md b/kimi.md new file mode 100644 index 0000000..2894221 --- /dev/null +++ b/kimi.md @@ -0,0 +1,150 @@ +# Kimi-Kontext: Hocks Mieterportal (mieterportal.hocks.eu) + +> Projekt-Kontextdatei für Kimi Work. Stand: 02.10.2026, 11:00 Uhr. +> Bitte bei größeren Änderungen am Projekt aktualisieren. +> Konventionen und Workflow sind am Projekt Schulportal (sfg.hocks.eu) orientiert. + +## 1. Projektüberblick + +Mieterportal der Hocks Verwaltung für **50 Wohnungen in 3 Häusern** (Hauptstraße 12 · +Ringweg 3 · Am Park 5). Analog zum Schulportal: alle Mietangelegenheiten digital an +einem Ort. **Rollenmodus statt echter Anmeldung** (Demo): Umschalten Mieter ↔ +Verwaltung über die Seitenleiste (Schaltfläche „Als Verwaltung anzeigen"). + +**Die 5 Module:** + +1. **Dashboard** (`/`) – Mieter: Begrüßung, nächste Zahlung, Mietdiagramm, Ankündigungen, + offene Meldungen. Verwaltung: Kennzahlen (Vermietungsquote, Miet-Soll, Rückstände), + Einnahmenverlauf + Belegung pro Haus (Recharts). +2. **Mietkonto & Zahlungen** (`/mietkonto`) – Buchungsverlauf 2026 mit Statusfilter, + Nebenkostenguthaben, Zahlungsdaten. Verwaltung: Zahlungsstatus aller Wohnungen, + Rückstandsliste, Haus-Filter. +3. **Schadensmeldungen** (`/schadensmeldungen`) – Tickets mit Kategorie, Dringlichkeit, + Status; Neuanlage per Dialog; Verwaltung pflegt Status per Select. +4. **Dokumente** (`/dokumente`) – Verträge/Abrechnungen/Hausordnung/Protokolle, + Suche + Kategorie-Chips. Mieter sieht eigene + Hausdokumente (unitId=null). +5. **Nachrichten** (`/nachrichten`) – Chat Mieter ↔ Verwaltung, Ungelesen-Markierung, + neue Unterhaltungen. + +**Login-Seite** (`/login`): Hero mit generiertem Foto, Feature-Liste, Demo-Einstiege +(„Als Mieterin anmelden" / „Als Verwaltung anmelden"). Kein echtes Auth – der +Rollenwechsel setzt nur den Store. + +## 2. Technischer Stack + +| Teil | Technologie | +|---|---| +| Frontend | React 18 + TypeScript, Vite 7, Tailwind v3.4 + shadcn/ui (Radix), React Router 7 (BrowserRouter), Recharts | +| State | React Context + useReducer, Persistenz in localStorage (`hocks-mieterportal-v1`) | +| Daten | `src/data/seed.ts` (handschriftlich) + `src/data/generated.json` (maschinell via `beispieldaten.py`) | +| Branding | KI-generiert: `src/assets/logo.png` (Haus-Schlüssel-Emblem, transparent), `hero.jpg`, `modules.jpg` | +| Webserver (Ziel) | Docker-Container `mp-portal` (nginx), statisches `dist/`, gemountet `/opt/mieterportal/dist` | +| Mail (optional) | `mail-server/`: Postfix-Relay (`mp-mail`) + Python-API (`mp-mail-api`), Muster aus Schulportal | +| Server | Root-VPS hocks.eu (IONOS), Docker + Portainer, Subdomain via NPM (Nginx Proxy Manager, Let's Encrypt) | + +## 3. Wichtige Dateien + +Projektverzeichnis: `C:\Users\ah\Documents\Kimi\Workspaces\Mieterportal` +**Git:** `https://git.hocks.eu/ah-admin/Mieterportal` (Branch `main`). Secrets +ausgesperrt via `.gitignore` (`mail-server/mail.env` etc.). + +| Datei | Inhalt | +|---|---| +| `src/store/store.tsx` | Store-Provider, Reducer, Actions (setRole/addTicket/setTicketStatus/addMessage/newThread/markThreadRead), `fmtEUR` | +| `src/data/seed.ts` | Tickets, Dokumente, Nachrichten-Threads, Ankündigungen + Re-Export units/transactions aus generated.json | +| `src/data/generated.json` | 50 Wohnungen + 552 Buchungen, erzeugt von `beispieldaten.py` (Seed 20261002) | +| `beispieldaten.py` | Deterministischer Generator: `python beispieldaten.py [seed]` | +| `src/types/index.ts` | Alle Typen (Unit, Transaction, Ticket, DocumentItem, Thread, Announcement) | +| `src/components/AppLayout.tsx` | Sidebar (Desktop) + Mobile Top-Bar/Nav, Rollenwechsel, Ungelesen-Badge, ThemeToggle | +| `src/components/ThemeToggle.tsx` | Dark-Mode-Umschalter (Sonne/Mond) | +| `src/lib/theme.ts` | `mp-theme` in localStorage, Init vor erstem Render (main.tsx) | +| `src/pages/Login.tsx` | Landing/Login mit Hero-Bild und Demo-Einstiegen | +| `Dockerfile` | Optionaler All-in-One-Build (node build → nginx) für `docker build`-Workflow | +| `docker/nginx.conf` | nginx-Site: SPA-Fallback, Gzip, Cache-Header für /assets/, Security-Header | +| `docker-compose.yml` | `mp-portal` (nginx + gemountetes dist) im externen Netz `npm_default`; Mail-Stack separat | +| `deploy.sh` | Bewährter Deploy-Workflow (s. Kap. 4) | +| `mail-server/` | Postfix-Relay + schlanke Mail-API (Muster Schulportal, angepasst) | + +## 4. Deploy-Workflow (bewährt, aus Schulportal übernommen) + +```bash +# 1. Bauen (im Projektverzeichnis) +npm run build + +# 2. Paketieren + Hochladen (pscp braucht cwd = Projektverzeichnis) +tar -czf mp-dist.tar.gz -C dist . +"/c/Program Files/PuTTY/pscp.exe" -batch \ + -hostkey "SHA256:4G3UxsugNkHH6BrPXScLXh+vX8P1sLgWKn8cZBKSRxw" \ + -i "Z:\Devel\ssh\priv_ionos_hocks_vps.ppk" -P 38592 \ + mp-dist.tar.gz root@hocks.eu:/tmp/ + +# 3. Entpacken + Container neu starten + Hash prüfen +"/c/Program Files/PuTTY/plink.exe" -batch -hostkey "SHA256:4G3UxsugNkHH6BrPXScLXh+vX8P1sLgWKn8cZBKSRxw" \ + -i "Z:\Devel\ssh\priv_ionos_hocks_vps.ppk" -P 38592 root@hocks.eu \ + "rm -rf /opt/mieterportal/dist && mkdir -p /opt/mieterportal/dist && \ + tar -xzf /tmp/mp-dist.tar.gz -C /opt/mieterportal/dist && \ + cd /opt/mieterportal && docker compose up -d && sleep 2 && \ + curl -s https://mieterportal.hocks.eu/ | grep -o 'index-[^\"]*\.js'" + +# Alternativ alles in einem Schritt: +./deploy.sh +``` + +- SSH: Port **38592**, Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk` (Pageant), Hostkey-Flag zwingend. +- **Vorsicht Fail2Ban:** zu viele gescheiterte SSH-Versuche von einer IP führen zu einem + Ban (Timeout-Verbindungsbild). Ban aufheben auf dem Server: `fail2ban-client unban --all`. +- Erst-Einrichtung auf dem Server (einmalig): + `mkdir -p /opt/mieterportal` + `docker-compose.yml` und `docker/nginx.conf` dorthin kopieren, + dann `docker compose up -d`. NPM-Proxy-Host `mieterportal.hocks.eu` → Container `mp-portal:80`. +- HTML wird mit `no-cache` geliefert (nginx-Config), Assets hash-benannt → Reload genügt. + Der eingebettete Kimi-Browser cached hart → beim Testen Cache-Buster anhängen: `?v=X`. + +## 5. Zugänge & Konfiguration + +- Keine echten Zugänge (Demo). Rollenwechsel im UI; Store-Reset über Browser-DevTools + (localStorage-Key `hocks-mieterportal-v1` löschen). +- Demo-Mieterin: **Anna Berger**, Hauptstraße 12, Whg. 7 (id `hs-7`). +- **Mail-API-Token:** bewusst nicht hier dokumentiert – liegt in `mail-server/mail.env` + (nicht im Repo; Vorlage `mail.env.example`). + +## 6. Konventionen & Besonderheiten + +- **Dark Mode:** class-Strategie, Präferenz `mp-theme`, Init vor erstem Render. + Feste Farbklassen werden in `index.css` für `.dark` umrechnen + (`.dark .bg-white`/`.bg-slate-50`/`.bg-teal-50`); Sidebar ist eigenes Dunkel-Teal. +- **Barrierefreiheit:** aria-pressed/labels am ThemeToggle und Rollenwechsel, Fokus-Ringe. +- **Formatierung:** Beträge via `fmtEUR` (Intl de-DE), Datumsformate TT.MM.JJJJ. +- **Datenpflege:** Bestandsdaten ändern → `beispieldaten.py` anpassen und neu ausführen, + nicht generated.json von Hand editieren. +- **Sicherheit:** kein offenes Relay (Postfix mynetworks loopback + Docker-Netz, + sender_canonical zentral). Upload/Download-Funktionen sind Demo-Darstellungen. +- **Keine destruktiven Aktionen auf dem Server ohne ausdrückliche Bestätigung.** + +## 7. Testing + +- `npm run build` (`tsc -b`) ist die echte Typprüfung; `npx tsc --noEmit` prüft wegen + Solution-tsconfig ggf. nichts. +- Headless-Screenshots (Dark Mode = Systempräferenz): + `"/c/Program Files/Google/Chrome/Application/chrome.exe" --headless --disable-gpu --screenshot=... --window-size=WxH "URL"` +- Screenshots ausgeliefert nach `preview/`. + +## 8. Verlauf / offene Punkte + +**Erledigt 02.10. (v1.0):** Portal komplett neu aufgesetzt (5 Module, Mieter-/Verwaltungsansicht, +KI-Branding: Logo/Hero/Illustration), Docker-Setup (Dockerfile, nginx, compose), +Repo in git.hocks.eu angelegt, Deploy-Workflow + Dark Mode + beispieldaten.py aus dem +Schulportal übernommen, mail-server-Struktur angelegt (noch nicht live verkabelt). + +**Offen:** +- Deployment auf hocks.eu ausstehend (SSH-Zugang per Pageant-ppk geklärt; Fail2Ban-Ban + nach früher Fehlversuchen abwarten/aufheben, dann `./deploy.sh`). +- Mail-Stack optional einrichten: `mail.env` aus Vorlage befüllen, Relay-Ziel in + `postfix/main.cf` setzen, npm_default-Netz prüfen. +- Mögliche nächste Schritte: echte Anmeldung + serverseitige Datenbank (PostgreSQL/Node-API), + E-Mail-Benachrichtigung bei neuen Schadensmeldungen, Mehrseitigkeit für 50 echte Mietverhältnisse. + +## 9. Sonstiges + +- User kommuniziert auf Deutsch; alle UI-Texte und Antworten auf Deutsch. +- Dateien außerhalb des Workspaces immer erst in den Workspace kopieren. +- Vorsicht mit destruktiven Aktionen (Explizite Bestätigung einholen). diff --git a/mail-server/api/__pycache__/server.cpython-312.pyc b/mail-server/api/__pycache__/server.cpython-312.pyc new file mode 100644 index 0000000..8ae53f9 Binary files /dev/null and b/mail-server/api/__pycache__/server.cpython-312.pyc differ diff --git a/mail-server/api/server.py b/mail-server/api/server.py new file mode 100644 index 0000000..a93490b --- /dev/null +++ b/mail-server/api/server.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Hocks Mieterportal – schlanke Mail-API (Muster: Schulportal). + +Nimmt Mails vom Portal entgegen (POST /send, Token-Pflicht) und leitet sie +über den internen Postfix-Relay (mp-mail:25) aus. Versand läuft über eine +Outbox mit Retry-Worker (Backoff bis 60 min, max. 10 Versuche) – fällt der +Versand aus (Rate-Limit etc.), wird automatisch erneut versucht. + +Reine Standardbibliothek (python:3.12-alpine, keine pip-Abhängigkeiten). + +Endpunkte: + GET /healthz → {"ok": true} + GET /outbox → aktuelle Outbox (Demo/Debug) + POST /send → {"ok": true, "id": ..., "versandt": true/false} + Body: {"to": "...", "subject": "...", "text": "...", "html": "..."} + Auth: Header X-Api-Token oder Body-Feld "token" == MAIL_API_TOKEN +""" + +import json +import os +import re +import smtplib +import threading +import time +import uuid +from email.message import EmailMessage +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# ── Konfiguration ───────────────────────────────────────────── +RELAY_HOST = os.environ.get("RELAY_HOST", "mp-mail") +RELAY_PORT = int(os.environ.get("RELAY_PORT", "25")) +PORT = int(os.environ.get("PORT", "8080")) +ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*") +DATA_DIR = os.environ.get("DATA_DIR", "/data") +SENDER_ADDRESS = os.environ.get("SENDER_ADDRESS", "noreply@hocks.eu") +SENDER_NAME = os.environ.get("SENDER_NAME", "Hocks Mieterportal") +MAIL_API_TOKEN = os.environ.get("MAIL_API_TOKEN", "") +RATE_LIMIT = int(os.environ.get("RATE_LIMIT", "500")) # Mails pro Stunde + +MAX_VERSUCHE = 10 +BACKOFF_MAX_SEK = 3600 + +os.makedirs(DATA_DIR, exist_ok=True) +OUTBOX_DATEI = os.path.join(DATA_DIR, "outbox.json") + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + +_lock = threading.Lock() +_send_log: list[float] = [] # Timestamps für Rate-Limit + + +# ── Outbox ──────────────────────────────────────────────────── +def outbox_laden() -> list[dict]: + try: + with open(OUTBOX_DATEI, encoding="utf-8") as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return [] + + +def outbox_speichern(eintraege: list[dict]) -> None: + tmp = OUTBOX_DATEI + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(eintraege, f, ensure_ascii=False, indent=1) + os.replace(tmp, OUTBOX_DATEI) + + +def rate_limit_ok() -> bool: + jetzt = time.time() + global _send_log + with _lock: + _send_log = [t for t in _send_log if jetzt - t < 3600] + if len(_send_log) >= RATE_LIMIT: + return False + _send_log.append(jetzt) + return True + + +def versenden(e: dict) -> bool: + msg = EmailMessage() + msg["From"] = f"{SENDER_NAME} <{SENDER_ADDRESS}>" + msg["To"] = e["to"] + msg["Subject"] = e["subject"] + msg.set_content(e.get("text") or "") + if e.get("html"): + msg.add_alternative(e["html"], subtype="html") + with smtplib.SMTP(RELAY_HOST, RELAY_PORT, timeout=20) as smtp: + smtp.send_message(msg) + return True + + +def outbox_eintrag(e: dict) -> None: + eintraege = outbox_laden() + eintraege.append(e) + outbox_speichern(eintraege) + + +def worker() -> None: + """Retry-Worker: versucht offene Outbox-Einträge mit Backoff erneut.""" + while True: + time.sleep(30) + jetzt = time.time() + geaendert = False + eintraege = outbox_laden() + for e in eintraege: + if e.get("versandt") or e.get("versuche", 0) >= MAX_VERSUCHE: + continue + if jetzt - e.get("letzterVersuch", 0) < min(60 * 2 ** e.get("versuche", 0), BACKOFF_MAX_SEK): + continue + try: + e["versuche"] = e.get("versuche", 0) + 1 + e["letzterVersuch"] = jetzt + versenden(e) + e["versandt"] = True + e["versandtAm"] = time.strftime("%d.%m.%Y %H:%M:%S") + except OSError: + pass # nächster Versuch später + geaendert = True + if geaendert: + outbox_speichern(eintraege) + + +# ── HTTP ────────────────────────────────────────────────────── +class Handler(BaseHTTPRequestHandler): + server_version = "mp-mail-api/1.0" + + def _antwort(self, code: int, body: dict) -> None: + daten = json.dumps(body, ensure_ascii=False).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(daten))) + if ALLOWED_ORIGIN != "*": + self.send_header("Access-Control-Allow-Origin", ALLOWED_ORIGIN) + self.end_headers() + self.wfile.write(daten) + + def _body(self) -> dict: + try: + laenge = int(self.headers.get("Content-Length", "0")) + return json.loads(self.rfile.read(laenge) or b"{}") + except (ValueError, json.JSONDecodeError): + return {} + + def do_OPTIONS(self) -> None: # noqa: N802 + self.send_response(204) + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Api-Token") + self.end_headers() + + def do_GET(self) -> None: # noqa: N802 + if self.path.split("?")[0] == "/healthz": + self._antwort(200, {"ok": True, "service": "mp-mail-api"}) + elif self.path.split("?")[0] == "/outbox": + self._antwort(200, {"eintraege": outbox_laden()}) + else: + self._antwort(404, {"fehler": "nicht gefunden"}) + + def do_POST(self) -> None: # noqa: N802 + if self.path.split("?")[0] != "/send": + self._antwort(404, {"fehler": "nicht gefunden"}) + return + body = self._body() + token = self.headers.get("X-Api-Token") or body.get("token") or "" + if not MAIL_API_TOKEN or token != MAIL_API_TOKEN: + self._antwort(401, {"fehler": "ungueltiger Token"}) + return + an = str(body.get("to", "")).strip() + betreff = str(body.get("subject", "")).strip() + if not EMAIL_RE.match(an) or not betreff: + self._antwort(400, {"fehler": "Empfaenger oder Betreff ungueltig"}) + return + if not rate_limit_ok(): + self._antwort(429, {"fehler": "Rate-Limit erreicht"}) + return + eintrag = { + "id": uuid.uuid4().hex[:12], + "to": an, + "subject": betreff, + "text": str(body.get("text", "")), + "html": str(body.get("html", "")), + "versuche": 0, + "versandt": False, + "angenommenAm": time.strftime("%d.%m.%Y %H:%M:%S"), + } + try: + versenden(eintrag) + eintrag["versandt"] = True + eintrag["versandtAm"] = eintrag["angenommenAm"] + eintrag["letzterVersuch"] = time.time() + except OSError: + outbox_eintrag(eintrag) # Worker versucht es spaeter erneut + self._antwort(200, {"ok": True, "id": eintrag["id"], "versandt": eintrag["versandt"]}) + + def log_message(self, fmt: str, *args) -> None: + print(f"[{time.strftime('%H:%M:%S')}] {self.address_string()} {fmt % args}") + + +if __name__ == "__main__": + threading.Thread(target=worker, daemon=True).start() + print(f"mp-mail-api lauscht auf :{PORT} (Relay {RELAY_HOST}:{RELAY_PORT}, Limit {RATE_LIMIT}/h)") + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/mail-server/docker-compose.yml b/mail-server/docker-compose.yml new file mode 100644 index 0000000..b14706b --- /dev/null +++ b/mail-server/docker-compose.yml @@ -0,0 +1,53 @@ +# Hocks Mieterportal – Mail-Stack (Muster aus dem Schulportal-Projekt) +# +# Postfix-Relay: nimmt Mails intern entgegen und liefert sie über den +# konfigurierten Relayhost aus. Port 25 wird NICHT nach außen veröffentlicht – +# nur im Docker-Netz (npm_default, erreichbar für den NPM-Proxy). +# +# Start: cd mail-server && cp mail.env.example mail.env # Werte befüllen +# docker compose up -d --build + +services: + # Postfix-Relay (nur intern) + mp-mail: + build: ./postfix + image: mp-postfix:latest + container_name: mp-mail + restart: unless-stopped + env_file: mail.env + environment: + # Direktzustellung wie Schulportal, z. B.: + # RELAY_HOST: "[]:25" + RELAY_HOST: "${RELAY_HOST:-}" + expose: + - "25" + networks: + - npm_default + + # Kleine HTTP-API, über die das Portal Mails an den Relay übergibt. + mp-mail-api: + image: python:3.12-alpine + container_name: mp-mail-api + restart: unless-stopped + env_file: mail.env + environment: + RELAY_HOST: mp-mail + RELAY_PORT: "25" + PORT: "8080" + ALLOWED_ORIGIN: https://mieterportal.hocks.eu + DATA_DIR: /data + command: ["python", "/app/server.py"] + volumes: + - ./api/server.py:/app/server.py:ro + - mp-mail-data:/data + depends_on: + - mp-mail + networks: + - npm_default + +volumes: + mp-mail-data: + +networks: + npm_default: + external: true diff --git a/mail-server/mail.env.example b/mail-server/mail.env.example new file mode 100644 index 0000000..c353777 --- /dev/null +++ b/mail-server/mail.env.example @@ -0,0 +1,13 @@ +# Hocks Mieterportal – Mail-Relay-Konfiguration +# Absenderadresse, auf die alle Mails umgeschrieben werden (Nicht-Antworten-Konto) +CANONICAL_ADDRESS=noreply@hocks.eu + +# Absender, den die Mail-API im From-Header setzt (sollte = CANONICAL_ADDRESS) +SENDER_ADDRESS=noreply@hocks.eu + +# Optional: Relayhost fuer Postfix (Direktzustellung wie Schulportal), +# z. B. "[]:25" – leer lassen = direkte Auslieferung +RELAY_HOST= + +# Token fuer die Mail-API (wird spaeter ggf. vom nginx-Proxy unter /api/ injiziert) +MAIL_API_TOKEN= diff --git a/mail-server/postfix/Dockerfile b/mail-server/postfix/Dockerfile new file mode 100644 index 0000000..b67de4e --- /dev/null +++ b/mail-server/postfix/Dockerfile @@ -0,0 +1,13 @@ +FROM alpine:3.20 + +RUN apk add --no-cache postfix ca-certificates + +COPY main.cf /etc/postfix/main.cf +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh && \ + mkdir -p /var/spool/postfix /etc/postfix + +# Port 25 nur intern im Docker-Netz (kein -p beim Start!) +EXPOSE 25 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/mail-server/postfix/entrypoint.sh b/mail-server/postfix/entrypoint.sh new file mode 100644 index 0000000..e4d69dd --- /dev/null +++ b/mail-server/postfix/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +# Absender-Umschreibung auf die Nicht-Antworten-Adresse des hocks.eu-Tenants. +# Bei Direktzustellung an den M365-Eingang vertraut der Konnektor der +# Absender-IP – Absenderadresse wird zentral umgeschrieben, damit keine +# fremden Absenderdomains entstehen. +CANONICAL_ADDRESS="${CANONICAL_ADDRESS:-noreply@hocks.eu}" +printf '/.+/ %s\n' "$CANONICAL_ADDRESS" > /etc/postfix/sender_canonical + +# Optionalen Relayhost aus der Umgebung setzen (z. B. [mx-von-hocks.eu]:25) +if [ -n "${RELAY_HOST:-}" ]; then + postconf -e "relayhost = ${RELAY_HOST}" +fi + +# Chroot in Containern deaktivieren (sonst lokale Unix-Sockets nicht erreichbar) +postconf -F '*/*/chroot=n' + +echo "MP-Mail-Relay startet (Relayhost: ${RELAY_HOST:-direkt}, Absender -> $CANONICAL_ADDRESS)" +exec postfix start-fg diff --git a/mail-server/postfix/main.cf b/mail-server/postfix/main.cf new file mode 100644 index 0000000..a5055ea --- /dev/null +++ b/mail-server/postfix/main.cf @@ -0,0 +1,32 @@ +# Hocks Mail-Relay (Mieterportal): liefert intern angenommene Mails über den +# konfigurierten Relayhost aus (O365-MX-Direktzustellung wie im Schulportal, +# oder smarthost mit AUTH-Erweiterung möglich). +# KEIN SMTP AUTH noetig bei Direktzustellung – wichtig: Container ist nicht +# oeffentlich exponiert, Relaying nur aus dem Docker-Netz (mynetworks). +compatibility_level = 3.6 + +myhostname = mail.mieterportal.hocks.eu +mydomain = hocks.eu +myorigin = $myhostname + +# relayhost wird zur Laufzeit vom Entrypoint aus $RELAY_HOST gesetzt +# (Direktzustellung: [mx-von-hocks.eu]:25). Leer = direkte Auslieferung. +smtp_tls_security_level = encrypt +smtp_tls_CApath = /etc/ssl/certs +smtp_tls_loglevel = 1 + +# Alle Absender (Envelope + Header) auf die Nicht-Antworten-Adresse umschreiben +sender_canonical_classes = envelope_sender, header_sender +sender_canonical_maps = regexp:/etc/postfix/sender_canonical + +# Nur intern erreichbar – Relaying nur aus dem Docker-Netz erlaubt (kein offenes Relay!) +inet_interfaces = all +inet_protocols = ipv4 +mynetworks = 127.0.0.0/8 172.16.0.0/12 +smtpd_relay_restrictions = permit_mynetworks, defer +smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination +smtpd_client_restrictions = permit_mynetworks, reject + +maillog_file = /dev/stdout +biff = no +append_dot_mydomain = no diff --git a/preview/01-start.png b/preview/01-start.png new file mode 100644 index 0000000..099250c Binary files /dev/null and b/preview/01-start.png differ diff --git a/preview/02-dashboard-mieter.png b/preview/02-dashboard-mieter.png new file mode 100644 index 0000000..d048cf5 Binary files /dev/null and b/preview/02-dashboard-mieter.png differ diff --git a/preview/03-mietkonto.png b/preview/03-mietkonto.png new file mode 100644 index 0000000..efc9bb8 Binary files /dev/null and b/preview/03-mietkonto.png differ diff --git a/preview/04-schadensmeldungen.png b/preview/04-schadensmeldungen.png new file mode 100644 index 0000000..1897bee Binary files /dev/null and b/preview/04-schadensmeldungen.png differ diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 2ba1b3a..18298bf 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -10,6 +10,7 @@ import { Bell, } from 'lucide-react' import { useStore } from '../store/store' +import ThemeToggle from './ThemeToggle' import logo from '../assets/logo.png' const navItems = [ @@ -27,7 +28,7 @@ export default function AppLayout() { ).length return ( -
+
{/* Sidebar */}
{/* Mobile top bar */}
-
+
Hocks Logo -
Hocks Mieterportal
+
Hocks Mieterportal
+
-
@@ -276,7 +276,7 @@ function AdminDashboard() { fmtEUR.format(Number(v))} /> - +
@@ -291,8 +291,8 @@ function AdminDashboard() { - - + +