feat: Schritte 14–15 — Google Calendar OAuth + Apple CalDAV Sync + Settings-Seite
- server/services/google-calendar.js: OAuth 2.0, bidirektionaler Sync via Google Calendar API v3, inkrementeller syncToken, 410-Fallback auf Vollsync - server/services/apple-calendar.js: CalDAV via tsdav (dynamic ESM import), minimaler ICS-Parser + ICS-Builder, bidirektionaler Sync - server/routes/calendar.js: 7 neue Sync-Routen (google/auth, google/callback, google/sync, google/status, google/disconnect, apple/status, apple/sync) - server/db.js: Migration 2 — sync_config Tabelle + idx_calendar_external_id - server/db-schema-test.js: MIGRATIONS_SQL[2] für Tests synchronisiert - server/auth.js: PATCH /me/password Endpoint - server/index.js: Auto-Sync-Scheduler (setInterval, SYNC_INTERVAL_MINUTES) - public/pages/settings.js: vollständige Settings-Seite (Konto, Passwort, Kalender-Sync-Status + Aktionen, Familienmitglieder-Verwaltung) - public/styles/settings.css: neue Stylesheet-Datei - public/index.html + public/sw.js: settings.css eingebunden und gecacht - .env.example: SYNC_INTERVAL_MINUTES ergänzt - README.md: vollständige Setup-Anleitung, Google/Apple-Sync-Dokumentation, modernes GitHub-Layout mit Badges und aufklappbaren Abschnitten Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,9 @@ APPLE_CALDAV_URL=https://caldav.icloud.com
|
|||||||
APPLE_USERNAME=
|
APPLE_USERNAME=
|
||||||
APPLE_APP_SPECIFIC_PASSWORD=
|
APPLE_APP_SPECIFIC_PASSWORD=
|
||||||
|
|
||||||
|
# Kalender-Sync-Intervall in Minuten (Standard: 15)
|
||||||
|
SYNC_INTERVAL_MINUTES=15
|
||||||
|
|
||||||
# Sicherheit
|
# Sicherheit
|
||||||
RATE_LIMIT_WINDOW_MS=60000
|
RATE_LIMIT_WINDOW_MS=60000
|
||||||
RATE_LIMIT_MAX_ATTEMPTS=5
|
RATE_LIMIT_MAX_ATTEMPTS=5
|
||||||
|
|||||||
@@ -1,178 +1,284 @@
|
|||||||
# Oikos — Selbstgehosteter Familienplaner
|
<div align="center">
|
||||||
|
|
||||||
Oikos ist eine self-hosted Progressive Web App für Familien. Sie läuft vollständig auf deinem eigenen Server — keine Cloud-Abhängigkeiten, keine Datenweitergabe.
|
# 🏠 Oikos
|
||||||
|
|
||||||
## Features
|
**Selbstgehosteter Familienplaner — privat, offen, ohne Abonnement**
|
||||||
|
|
||||||
- **Dashboard** — Begrüßung, Wetter-Widget, anstehende Termine, dringende Aufgaben, Essen, Pinnwand
|
[](https://nodejs.org)
|
||||||
- **Aufgaben** — Listenansicht (Kategorie/Fälligkeit), Kanban-Board, Teilaufgaben, Swipe-Gesten, wiederkehrende Aufgaben
|
[](https://www.docker.com)
|
||||||
- **Einkaufslisten** — Mehrere Listen, Kategorien, Essensplan-Integration
|
[](https://www.zetetic.net/sqlcipher/)
|
||||||
- **Essensplan** — Wochenansicht, Zutatenverwaltung, Übertrag auf Einkaufsliste
|
[](https://web.dev/progressive-web-apps/)
|
||||||
- **Kalender** — Monats-/Wochen-/Tages-/Agenda-Ansicht, Familienfarben, wiederkehrende Termine
|
[](./LICENSE)
|
||||||
- **Pinnwand** — Farbige Sticky Notes mit Markdown-Light
|
|
||||||
- **Kontakte** — Wichtige Kontakte mit Kategorie-Filter, tel:/mailto:/Maps-Links
|
|
||||||
- **Budget** — Einnahmen/Ausgaben, Monatsauswertung, CSV-Export
|
|
||||||
- **PWA** — Offline-fähig, installierbar auf iOS/Android/Desktop
|
|
||||||
|
|
||||||
## Voraussetzungen
|
Alle Daten bleiben auf deinem eigenen Server.
|
||||||
|
Kein Cloud-Zwang. Keine Datenweitergabe. Kein Tracking.
|
||||||
|
|
||||||
- **Docker & Docker Compose** (empfohlen) oder **Node.js ≥ 20**
|
[Module](#module) · [Schnellstart](#schnellstart) · [Konfiguration](#konfiguration) · [Kalender-Sync](#kalender-synchronisation) · [Sicherheit](#sicherheit)
|
||||||
- Ein Linux-Server hinter Nginx Reverse Proxy mit SSL (empfohlen)
|
|
||||||
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schnellstart mit Docker (empfohlen)
|
## Module
|
||||||
|
|
||||||
### 1. Repository klonen
|
| | Modul | Highlights |
|
||||||
|
|---|---|---|
|
||||||
|
| 📋 | **Dashboard** | Wetter-Widget, anstehende Termine, dringende Aufgaben, Essen heute, Pinnwand-Vorschau |
|
||||||
|
| ✅ | **Aufgaben** | Listenansicht + Kanban, Teilaufgaben, Swipe-Gesten, wiederkehrende Aufgaben (RRULE) |
|
||||||
|
| 🛒 | **Einkauf** | Mehrere Listen, automatische Kategorie-Sortierung, Integration mit Essensplan |
|
||||||
|
| 🍽️ | **Essensplan** | Wochenansicht, Zutatenverwaltung, Zutaten → Einkaufsliste mit einem Klick |
|
||||||
|
| 📅 | **Kalender** | Monats-/Wochen-/Tages-/Agenda-Ansicht, Google Calendar & Apple Calendar Sync |
|
||||||
|
| 📌 | **Pinnwand** | Farbige Sticky Notes, Markdown-Light (fett, kursiv, Listen) |
|
||||||
|
| 👥 | **Kontakte** | Wichtige Familien-Kontakte, Direktanruf (`tel:`), Maps-Links |
|
||||||
|
| 💰 | **Budget** | Einnahmen/Ausgaben, Kategorien, Monatsvergleich, CSV-Export |
|
||||||
|
| ⚙️ | **Einstellungen** | Passwort ändern, Kalender-Sync verwalten, Familienmitglieder anlegen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
**Backend:** Node.js · Express · SQLite/SQLCipher · express-session · bcrypt
|
||||||
|
|
||||||
|
**Frontend:** Vanilla JavaScript (ES-Module) · Kein Framework · Kein Build-Step
|
||||||
|
|
||||||
|
**Deployment:** Docker · Nginx Reverse Proxy · PWA (Service Worker + Manifest)
|
||||||
|
|
||||||
|
**Optional:** Google Calendar API v3 (OAuth 2.0) · Apple iCloud CalDAV (tsdav)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- **Docker** + **Docker Compose**
|
||||||
|
- Ein Linux-Server mit Nginx Reverse Proxy und SSL (empfohlen: [Nginx Proxy Manager](https://nginxproxymanager.com))
|
||||||
|
|
||||||
|
### 1 — Repository klonen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ulsklyc/oikos.git
|
git clone https://github.com/ulsklyc/oikos.git
|
||||||
cd oikos
|
cd oikos
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Umgebungsvariablen konfigurieren
|
### 2 — Umgebungsvariablen setzen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Pflichtfelder in `.env` anpassen:
|
Mindestens diese zwei Pflichtfelder in `.env` ausfüllen:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
SESSION_SECRET=ein-langer-zufaelliger-string-min-32-zeichen
|
# Langen zufälligen String (≥ 32 Zeichen)
|
||||||
DB_ENCRYPTION_KEY=ein-starkes-passwort-fuer-die-datenbank
|
SESSION_SECRET=...
|
||||||
|
|
||||||
|
# AES-256-Schlüssel für SQLCipher-Datenbankverschlüsselung
|
||||||
|
DB_ENCRYPTION_KEY=...
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional: Wetter-Widget aktivieren
|
> Vollständige Variablen-Referenz → [Konfiguration](#konfiguration)
|
||||||
|
|
||||||
```env
|
### 3 — Container starten
|
||||||
OPENWEATHER_API_KEY=dein-api-key-von-openweathermap.org
|
|
||||||
OPENWEATHER_CITY=Berlin
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Starten
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Die App ist unter `http://localhost:3000` erreichbar.
|
> Der erste Build dauert 2–3 Minuten (SQLCipher wird gegen better-sqlite3 kompiliert).
|
||||||
|
|
||||||
### 4. Erster Login
|
### 4 — Admin-Account anlegen
|
||||||
|
|
||||||
Beim ersten Start wird automatisch ein Admin-Account erstellt:
|
|
||||||
|
|
||||||
```
|
|
||||||
Benutzername: admin
|
|
||||||
Passwort: admin
|
|
||||||
```
|
|
||||||
|
|
||||||
**Passwort sofort ändern!** → Einstellungen → Passwort ändern
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ohne Docker (direkt mit Node.js)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
docker compose exec oikos node setup.js
|
||||||
cp .env.example .env
|
|
||||||
# .env anpassen (siehe oben)
|
|
||||||
npm start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Entwicklungsmodus (Auto-Reload):
|
Das interaktive Script fragt nach Benutzername, Anzeigename und Passwort. Dieser Account hat Admin-Rechte und kann weitere Familienmitglieder anlegen.
|
||||||
|
|
||||||
```bash
|
### 5 — App öffnen
|
||||||
npm run dev
|
|
||||||
```
|
`http://localhost:3000` — oder die konfigurierte Domain nach dem Nginx-Setup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Nginx Reverse Proxy
|
## Nginx Reverse Proxy
|
||||||
|
|
||||||
Beispiel-Konfiguration für Nginx Proxy Manager oder direktes Nginx:
|
Die Datei [`nginx.conf.example`](./nginx.conf.example) enthält eine vollständige Konfiguration.
|
||||||
|
|
||||||
```nginx
|
**Mit Nginx Proxy Manager:**
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
server_name oikos.deine-domain.de;
|
|
||||||
|
|
||||||
location / {
|
1. Neuen Proxy Host anlegen: `oikos.deine-domain.de` → `localhost:3000`
|
||||||
proxy_pass http://localhost:3000;
|
2. SSL-Zertifikat via Let's Encrypt ausstellen
|
||||||
proxy_set_header Host $host;
|
3. Inhalt aus `nginx.conf.example` im Feld "Advanced" eintragen
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
**Wichtig:** `X-Forwarded-Proto` muss gesetzt sein (in der Vorlage enthalten), damit Session-Cookies in Produktion korrekt als `Secure` gesetzt werden.
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
---
|
||||||
}
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Pflicht
|
||||||
|
|
||||||
|
| Variable | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| `SESSION_SECRET` | Zufälliger String ≥ 32 Zeichen für Session-Signing |
|
||||||
|
| `DB_ENCRYPTION_KEY` | SQLCipher AES-256-Schlüssel (leer = keine Verschlüsselung) |
|
||||||
|
|
||||||
|
### Wetter-Widget
|
||||||
|
|
||||||
|
Kostenlosen API-Key bei [openweathermap.org](https://openweathermap.org/api) registrieren:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OPENWEATHER_API_KEY=...
|
||||||
|
OPENWEATHER_CITY=Berlin
|
||||||
|
OPENWEATHER_UNITS=metric # metric = °C, imperial = °F
|
||||||
|
OPENWEATHER_LANG=de
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Datei `nginx.conf.example` im Repository enthält eine vollständige Konfiguration.
|
### Weitere Optionen
|
||||||
|
|
||||||
|
| Variable | Standard | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `3000` | Server-Port |
|
||||||
|
| `NODE_ENV` | `development` | `production` für Deployment |
|
||||||
|
| `DB_PATH` | `./oikos.db` | Pfad zur SQLite-Datei |
|
||||||
|
| `SYNC_INTERVAL_MINUTES` | `15` | Automatischer Kalender-Sync-Intervall |
|
||||||
|
| `RATE_LIMIT_MAX_ATTEMPTS` | `5` | Max. Login-Versuche pro Minute |
|
||||||
|
|
||||||
|
Vollständige Vorlage: [`.env.example`](./.env.example)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Familienmitglieder verwalten
|
## Kalender-Synchronisation
|
||||||
|
|
||||||
Neue Mitglieder können nur Admins anlegen:
|
### Google Calendar
|
||||||
|
|
||||||
1. **Einstellungen** → **Familienmitglieder** → **+ Neu**
|
<details>
|
||||||
2. Benutzername, Anzeigename, Passwort, Avatarfarbe und Rolle festlegen
|
<summary>Einrichtung anzeigen</summary>
|
||||||
3. Login-Daten dem Familienmitglied mitteilen
|
|
||||||
|
#### Google Cloud Console vorbereiten
|
||||||
|
|
||||||
|
1. Projekt unter [console.cloud.google.com](https://console.cloud.google.com) anlegen
|
||||||
|
2. **Google Calendar API** aktivieren
|
||||||
|
3. **OAuth 2.0-Client-ID** erstellen (Typ: „Webanwendung")
|
||||||
|
4. Autorisierte Redirect-URI eintragen:
|
||||||
|
```
|
||||||
|
https://oikos.deine-domain.de/api/v1/calendar/google/callback
|
||||||
|
```
|
||||||
|
5. In `.env` eintragen:
|
||||||
|
```env
|
||||||
|
GOOGLE_CLIENT_ID=...
|
||||||
|
GOOGLE_CLIENT_SECRET=...
|
||||||
|
GOOGLE_REDIRECT_URI=https://oikos.deine-domain.de/api/v1/calendar/google/callback
|
||||||
|
```
|
||||||
|
6. Container neu starten: `docker compose up -d`
|
||||||
|
|
||||||
|
#### Verbindung herstellen
|
||||||
|
|
||||||
|
1. Mit einem **Admin**-Konto einloggen
|
||||||
|
2. **Einstellungen → Kalender-Synchronisation → Mit Google verbinden**
|
||||||
|
3. Google-Konto autorisieren → automatische Weiterleitung zurück
|
||||||
|
|
||||||
|
**Sync-Verhalten:**
|
||||||
|
- Erster Sync: Events der letzten 3 Monate + nächsten 12 Monate
|
||||||
|
- Folge-Syncs: nur Änderungen via Google syncToken (effizient)
|
||||||
|
- Outbound: neue lokale Termine werden nach Google übertragen
|
||||||
|
- Konflikt: Google gewinnt bei gleichzeitiger Änderung
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Apple Calendar (iCloud CalDAV)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Einrichtung anzeigen</summary>
|
||||||
|
|
||||||
|
#### App-spezifisches Passwort erstellen
|
||||||
|
|
||||||
|
1. [appleid.apple.com](https://appleid.apple.com) → „Anmeldung und Sicherheit" → „App-spezifische Passwörter"
|
||||||
|
2. Neues Passwort für „Oikos" erstellen
|
||||||
|
3. In `.env` eintragen:
|
||||||
|
```env
|
||||||
|
APPLE_CALDAV_URL=https://caldav.icloud.com
|
||||||
|
APPLE_USERNAME=deine@apple-id.de
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||||
|
```
|
||||||
|
4. Container neu starten: `docker compose up -d`
|
||||||
|
|
||||||
|
Der Sync-Button erscheint automatisch in den Einstellungen.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Umgebungsvariablen — Referenz
|
## Familienmitglieder
|
||||||
|
|
||||||
| Variable | Pflicht | Standard | Beschreibung |
|
Neue Mitglieder können nur Admins anlegen — es gibt keinen öffentlichen Registrierungs-Endpoint.
|
||||||
|---|---|---|---|
|
|
||||||
| `PORT` | — | `3000` | Server-Port |
|
**Im Browser:** Einstellungen → Familienmitglieder → Mitglied hinzufügen
|
||||||
| `NODE_ENV` | — | `development` | `production` für Deployment |
|
|
||||||
| `SESSION_SECRET` | ✓ | — | Langer Zufalls-String (≥ 32 Zeichen) |
|
**Per Script** (z.B. für weiteren Admin):
|
||||||
| `DB_PATH` | — | `./oikos.db` | Pfad zur SQLite-Datenbankdatei |
|
```bash
|
||||||
| `DB_ENCRYPTION_KEY` | — | — | SQLCipher-Schlüssel (leer = keine Verschlüsselung) |
|
docker compose exec oikos node setup.js
|
||||||
| `OPENWEATHER_API_KEY` | — | — | API-Key von openweathermap.org |
|
```
|
||||||
| `OPENWEATHER_CITY` | — | `Berlin` | Stadtname für Wetter-Abfrage |
|
|
||||||
| `OPENWEATHER_UNITS` | — | `metric` | `metric` = °C, `imperial` = °F |
|
|
||||||
| `OPENWEATHER_LANG` | — | `de` | Sprache der Wetterbeschreibungen |
|
|
||||||
| `RATE_LIMIT_WINDOW_MS` | — | `60000` | Login Rate-Limit Fenster (ms) |
|
|
||||||
| `RATE_LIMIT_MAX_ATTEMPTS` | — | `5` | Max. Login-Versuche pro Fenster |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sicherheit
|
## Updates
|
||||||
|
|
||||||
- Sessions sind `httpOnly`, `SameSite=Strict` und in Produktion `Secure`
|
```bash
|
||||||
- CSRF-Schutz via Double Submit Cookie auf allen zustandsändernden Requests
|
git pull
|
||||||
- Passwörter mit bcrypt (Cost Factor 12) gehasht
|
docker compose up -d --build
|
||||||
- Globaler Rate-Limiter auf allen API-Endpoints (300 req/min)
|
```
|
||||||
- Strikter Login-Rate-Limiter (5 Versuche/Minute)
|
|
||||||
- Content Security Policy via Helmet
|
Datenbank-Migrationen laufen automatisch beim Start. Daten im Volume `oikos_data` bleiben erhalten.
|
||||||
- Datenbank optional mit SQLCipher verschlüsselt
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
# SESSION_SECRET setzen — DB_ENCRYPTION_KEY weglassen (kein SQLCipher lokal)
|
||||||
|
npm run dev # Server mit Auto-Reload
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # 146 Tests, 7 Suiten (In-Memory-SQLite, keine laufende App nötig)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Datensicherung
|
## Datensicherung
|
||||||
|
|
||||||
Die gesamte Datenbank liegt in einer einzigen Datei:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backup
|
# Backup erstellen
|
||||||
cp /data/oikos.db /backup/oikos-$(date +%Y%m%d).db
|
docker run --rm \
|
||||||
|
-v oikos_oikos_data:/data \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
alpine tar czf /backup/oikos-backup-$(date +%Y%m%d).tar.gz /data
|
||||||
|
|
||||||
# Docker-Volume sichern
|
# Backup wiederherstellen
|
||||||
docker run --rm -v oikos_data:/data -v $(pwd):/backup \
|
docker compose down
|
||||||
alpine tar czf /backup/oikos-backup.tar.gz /data
|
docker run --rm \
|
||||||
|
-v oikos_oikos_data:/data \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
alpine tar xzf /backup/oikos-backup-YYYYMMDD.tar.gz -C /
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tests ausführen
|
## Sicherheit
|
||||||
|
|
||||||
```bash
|
- Sessions: `httpOnly`, `SameSite=Strict`, `Secure` in Produktion, 7 Tage TTL
|
||||||
npm test
|
- CSRF-Schutz via Double Submit Cookie auf allen schreibenden Requests
|
||||||
```
|
- Passwörter mit bcrypt (Cost Factor 12) gehasht
|
||||||
|
- Login-Rate-Limit: 5 Versuche/Minute
|
||||||
Die Tests verwenden In-Memory-SQLite und benötigen keine laufende App-Instanz.
|
- API-Rate-Limit: 300 Requests/Minute pro IP
|
||||||
|
- Content Security Policy via Helmet
|
||||||
|
- Datenbank optional mit SQLCipher AES-256 verschlüsselt (im Docker-Container)
|
||||||
|
- Kein API-Endpoint ohne Session-Auth erreichbar (außer `/api/v1/auth/login`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
<link rel="stylesheet" href="/styles/notes.css" />
|
<link rel="stylesheet" href="/styles/notes.css" />
|
||||||
<link rel="stylesheet" href="/styles/contacts.css" />
|
<link rel="stylesheet" href="/styles/contacts.css" />
|
||||||
<link rel="stylesheet" href="/styles/budget.css" />
|
<link rel="stylesheet" href="/styles/budget.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/settings.css" />
|
||||||
|
|
||||||
<!-- Lucide Icons (CDN) -->
|
<!-- Lucide Icons (CDN) -->
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||||
|
|||||||
+390
-9
@@ -1,25 +1,406 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Settings
|
* Modul: Einstellungen (Settings)
|
||||||
* Zweck: Seite für das Settings-Modul
|
* Zweck: Benutzerkonto, Passwort, Kalender-Sync, Familienmitglieder
|
||||||
* Abhängigkeiten: /api.js
|
* Abhängigkeiten: /api.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '/api.js';
|
import { api, auth } from '/api.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {HTMLElement} container
|
* @param {HTMLElement} container
|
||||||
* @param {{ user: object }} context
|
* @param {{ user: object }} context
|
||||||
*/
|
*/
|
||||||
export async function render(container, { user }) {
|
export async function render(container, { user }) {
|
||||||
|
// URL-Parameter auswerten (z.B. nach OAuth-Callback)
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const syncOk = params.get('sync_ok');
|
||||||
|
const syncErr = params.get('sync_error');
|
||||||
|
|
||||||
|
// State für Familienmitglieder + Sync-Status
|
||||||
|
let users = [];
|
||||||
|
let googleStatus = { configured: false, connected: false, lastSync: null };
|
||||||
|
let appleStatus = { configured: false, lastSync: null };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [usersRes, gStatus, aStatus] = await Promise.allSettled([
|
||||||
|
user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }),
|
||||||
|
api.get('/calendar/google/status'),
|
||||||
|
api.get('/calendar/apple/status'),
|
||||||
|
]);
|
||||||
|
if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? [];
|
||||||
|
if (gStatus.status === 'fulfilled') googleStatus = gStatus.value;
|
||||||
|
if (aStatus.status === 'fulfilled') appleStatus = aStatus.value;
|
||||||
|
} catch (_) { /* non-critical */ }
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page">
|
<div class="page settings-page">
|
||||||
<div class="page__header">
|
<div class="page__header">
|
||||||
<h1 class="page__title">Settings</h1>
|
<h1 class="page__title">Einstellungen</h1>
|
||||||
</div>
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-state__title">Kommt bald.</div>
|
|
||||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${syncOk ? `<div class="settings-banner settings-banner--success">Kalender-Sync mit ${syncOk === 'google' ? 'Google' : 'Apple'} erfolgreich verbunden.</div>` : ''}
|
||||||
|
${syncErr ? `<div class="settings-banner settings-banner--error">Verbindung mit ${syncErr === 'google' ? 'Google' : 'Apple'} fehlgeschlagen. Bitte erneut versuchen.</div>` : ''}
|
||||||
|
|
||||||
|
<!-- Mein Konto -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="settings-section__title">Mein Konto</h2>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-user-info">
|
||||||
|
<div class="settings-avatar" style="background:${user?.avatar_color ?? '#007AFF'}">
|
||||||
|
${initials(user?.display_name)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="settings-user-info__name">${user?.display_name ?? ''}</div>
|
||||||
|
<div class="settings-user-info__username">@${user?.username ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-card__title">Passwort ändern</h3>
|
||||||
|
<form id="password-form" class="settings-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="current-password">Aktuelles Passwort</label>
|
||||||
|
<input class="form-input" type="password" id="current-password" autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="new-password">Neues Passwort</label>
|
||||||
|
<input class="form-input" type="password" id="new-password" autocomplete="new-password" minlength="8" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="confirm-password">Neues Passwort bestätigen</label>
|
||||||
|
<input class="form-input" type="password" id="confirm-password" autocomplete="new-password" minlength="8" required />
|
||||||
|
</div>
|
||||||
|
<div id="password-error" class="form-error" hidden></div>
|
||||||
|
<button type="submit" class="btn btn--primary">Passwort speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Kalender-Synchronisation -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="settings-section__title">Kalender-Synchronisation</h2>
|
||||||
|
|
||||||
|
<!-- Google Calendar -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-sync-header">
|
||||||
|
<div class="settings-sync-logo settings-sync-logo--google">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="settings-sync-info">
|
||||||
|
<div class="settings-sync-info__name">Google Calendar</div>
|
||||||
|
<div class="settings-sync-info__status ${googleStatus.connected ? 'settings-sync-info__status--connected' : ''}">
|
||||||
|
${googleStatus.connected
|
||||||
|
? `Verbunden${googleStatus.lastSync ? ` · Zuletzt: ${formatDate(googleStatus.lastSync)}` : ''}`
|
||||||
|
: googleStatus.configured ? 'Nicht verbunden' : 'Nicht konfiguriert (fehlende .env-Variablen)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${googleStatus.configured ? `
|
||||||
|
<div class="settings-sync-actions">
|
||||||
|
${googleStatus.connected ? `
|
||||||
|
<button class="btn btn--secondary" id="google-sync-btn">Jetzt synchronisieren</button>
|
||||||
|
${user?.role === 'admin' ? `<button class="btn btn--danger-outline" id="google-disconnect-btn">Verbindung trennen</button>` : ''}
|
||||||
|
` : `
|
||||||
|
${user?.role === 'admin' ? `<a href="/api/v1/calendar/google/auth" class="btn btn--primary">Mit Google verbinden</a>` : '<span class="form-hint">Nur Admin kann Google Calendar verbinden.</span>'}
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Apple Calendar -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-sync-header">
|
||||||
|
<div class="settings-sync-logo settings-sync-logo--apple">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="settings-sync-info">
|
||||||
|
<div class="settings-sync-info__name">Apple Calendar (iCloud)</div>
|
||||||
|
<div class="settings-sync-info__status ${appleStatus.configured ? 'settings-sync-info__status--connected' : ''}">
|
||||||
|
${appleStatus.configured
|
||||||
|
? `Konfiguriert${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}`
|
||||||
|
: 'Nicht konfiguriert (APPLE_CALDAV_URL, APPLE_USERNAME, APPLE_APP_SPECIFIC_PASSWORD in .env setzen)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${appleStatus.configured ? `
|
||||||
|
<div class="settings-sync-actions">
|
||||||
|
<button class="btn btn--secondary" id="apple-sync-btn">Jetzt synchronisieren</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Familienmitglieder (nur Admin) -->
|
||||||
|
${user?.role === 'admin' ? `
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="settings-section__title">Familienmitglieder</h2>
|
||||||
|
<div class="settings-card" id="members-card">
|
||||||
|
<ul class="settings-members" id="members-list">
|
||||||
|
${users.map(memberHtml).join('')}
|
||||||
|
</ul>
|
||||||
|
<button class="btn btn--primary settings-add-btn" id="add-member-btn">+ Mitglied hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card settings-card--hidden" id="add-member-form-card">
|
||||||
|
<h3 class="settings-card__title">Neues Familienmitglied</h3>
|
||||||
|
<form id="add-member-form" class="settings-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="new-username">Benutzername</label>
|
||||||
|
<input class="form-input" type="text" id="new-username" required autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="new-display-name">Anzeigename</label>
|
||||||
|
<input class="form-input" type="text" id="new-display-name" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="new-member-password">Passwort</label>
|
||||||
|
<input class="form-input" type="password" id="new-member-password" minlength="8" required autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="new-avatar-color">Farbe</label>
|
||||||
|
<input class="form-input form-input--color" type="color" id="new-avatar-color" value="#007AFF" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="new-role">Rolle</label>
|
||||||
|
<select class="form-input" id="new-role">
|
||||||
|
<option value="member">Mitglied</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="member-error" class="form-error" hidden></div>
|
||||||
|
<div class="settings-form-actions">
|
||||||
|
<button type="submit" class="btn btn--primary">Erstellen</button>
|
||||||
|
<button type="button" class="btn btn--secondary" id="cancel-add-member">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Abmelden -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<button class="btn btn--danger-outline settings-logout-btn" id="logout-btn">Abmelden</button>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
bindEvents(container, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Event-Binding
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function bindEvents(container, user) {
|
||||||
|
// Passwort ändern
|
||||||
|
const passwordForm = container.querySelector('#password-form');
|
||||||
|
if (passwordForm) {
|
||||||
|
passwordForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentPw = container.querySelector('#current-password').value;
|
||||||
|
const newPw = container.querySelector('#new-password').value;
|
||||||
|
const confirmPw = container.querySelector('#confirm-password').value;
|
||||||
|
const errorEl = container.querySelector('#password-error');
|
||||||
|
|
||||||
|
errorEl.hidden = true;
|
||||||
|
|
||||||
|
if (newPw !== confirmPw) {
|
||||||
|
showError(errorEl, 'Passwörter stimmen nicht überein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = passwordForm.querySelector('[type=submit]');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await api.patch('/auth/me/password', { current_password: currentPw, new_password: newPw });
|
||||||
|
passwordForm.reset();
|
||||||
|
window.oikos?.showToast('Passwort erfolgreich geändert.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showError(errorEl, err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Sync
|
||||||
|
const googleSyncBtn = container.querySelector('#google-sync-btn');
|
||||||
|
if (googleSyncBtn) {
|
||||||
|
googleSyncBtn.addEventListener('click', async () => {
|
||||||
|
googleSyncBtn.disabled = true;
|
||||||
|
googleSyncBtn.textContent = 'Synchronisiere…';
|
||||||
|
try {
|
||||||
|
await api.post('/calendar/google/sync', {});
|
||||||
|
window.oikos?.showToast('Google Calendar synchronisiert.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
|
} finally {
|
||||||
|
googleSyncBtn.disabled = false;
|
||||||
|
googleSyncBtn.textContent = 'Jetzt synchronisieren';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Disconnect (Admin)
|
||||||
|
const googleDisconnectBtn = container.querySelector('#google-disconnect-btn');
|
||||||
|
if (googleDisconnectBtn) {
|
||||||
|
googleDisconnectBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Google Calendar-Verbindung trennen?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete('/calendar/google/disconnect');
|
||||||
|
window.oikos?.showToast('Google Calendar getrennt.', 'default');
|
||||||
|
window.oikos?.navigate('/settings');
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Sync
|
||||||
|
const appleSyncBtn = container.querySelector('#apple-sync-btn');
|
||||||
|
if (appleSyncBtn) {
|
||||||
|
appleSyncBtn.addEventListener('click', async () => {
|
||||||
|
appleSyncBtn.disabled = true;
|
||||||
|
appleSyncBtn.textContent = 'Synchronisiere…';
|
||||||
|
try {
|
||||||
|
await api.post('/calendar/apple/sync', {});
|
||||||
|
window.oikos?.showToast('Apple Calendar synchronisiert.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
|
} finally {
|
||||||
|
appleSyncBtn.disabled = false;
|
||||||
|
appleSyncBtn.textContent = 'Jetzt synchronisieren';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mitglied hinzufügen (Admin)
|
||||||
|
const addMemberBtn = container.querySelector('#add-member-btn');
|
||||||
|
if (addMemberBtn) {
|
||||||
|
addMemberBtn.addEventListener('click', () => {
|
||||||
|
container.querySelector('#add-member-form-card').classList.remove('settings-card--hidden');
|
||||||
|
addMemberBtn.hidden = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelAddMember = container.querySelector('#cancel-add-member');
|
||||||
|
if (cancelAddMember) {
|
||||||
|
cancelAddMember.addEventListener('click', () => {
|
||||||
|
container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
|
||||||
|
container.querySelector('#add-member-btn').hidden = false;
|
||||||
|
container.querySelector('#add-member-form').reset();
|
||||||
|
container.querySelector('#member-error').hidden = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMemberForm = container.querySelector('#add-member-form');
|
||||||
|
if (addMemberForm) {
|
||||||
|
addMemberForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errorEl = container.querySelector('#member-error');
|
||||||
|
errorEl.hidden = true;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
username: container.querySelector('#new-username').value.trim(),
|
||||||
|
display_name: container.querySelector('#new-display-name').value.trim(),
|
||||||
|
password: container.querySelector('#new-member-password').value,
|
||||||
|
avatar_color: container.querySelector('#new-avatar-color').value,
|
||||||
|
role: container.querySelector('#new-role').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
const btn = addMemberForm.querySelector('[type=submit]');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await auth.createUser(data);
|
||||||
|
const list = container.querySelector('#members-list');
|
||||||
|
list.insertAdjacentHTML('beforeend', memberHtml(res.user));
|
||||||
|
addMemberForm.reset();
|
||||||
|
container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
|
||||||
|
container.querySelector('#add-member-btn').hidden = false;
|
||||||
|
window.oikos?.showToast(`${res.user.display_name} hinzugefügt.`, 'success');
|
||||||
|
bindDeleteButtons(container, user);
|
||||||
|
} catch (err) {
|
||||||
|
showError(errorEl, err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindDeleteButtons(container, user);
|
||||||
|
|
||||||
|
// Abmelden
|
||||||
|
const logoutBtn = container.querySelector('#logout-btn');
|
||||||
|
if (logoutBtn) {
|
||||||
|
logoutBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await auth.logout();
|
||||||
|
} finally {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDeleteButtons(container, user) {
|
||||||
|
container.querySelectorAll('[data-delete-user]').forEach((btn) => {
|
||||||
|
btn.replaceWith(btn.cloneNode(true)); // Doppelte Listener vermeiden
|
||||||
|
});
|
||||||
|
container.querySelectorAll('[data-delete-user]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = parseInt(btn.dataset.deleteUser, 10);
|
||||||
|
const name = btn.dataset.name;
|
||||||
|
if (!confirm(`${name} wirklich löschen?`)) return;
|
||||||
|
try {
|
||||||
|
await auth.deleteUser(id);
|
||||||
|
btn.closest('.settings-member').remove();
|
||||||
|
window.oikos?.showToast(`${name} gelöscht.`, 'default');
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Helfer
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function memberHtml(u) {
|
||||||
|
return `
|
||||||
|
<li class="settings-member" data-id="${u.id}">
|
||||||
|
<div class="settings-avatar settings-avatar--sm" style="background:${u.avatar_color}">${initials(u.display_name)}</div>
|
||||||
|
<div class="settings-member__info">
|
||||||
|
<span class="settings-member__name">${u.display_name}</span>
|
||||||
|
<span class="settings-member__meta">@${u.username} · ${u.role === 'admin' ? 'Admin' : 'Mitglied'}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${u.display_name}" aria-label="${u.display_name} löschen" title="Löschen">
|
||||||
|
<i data-lucide="trash-2" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name) {
|
||||||
|
if (!name) return '?';
|
||||||
|
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(el, msg) {
|
||||||
|
el.textContent = msg;
|
||||||
|
el.hidden = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Einstellungen (Settings)
|
||||||
|
* Zweck: Styles für die Settings-Seite
|
||||||
|
* Abhängigkeiten: tokens.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Seiten-Layout
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Banner (Erfolg / Fehler nach OAuth-Callback)
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-banner {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-banner--success {
|
||||||
|
background: rgba(52, 199, 89, 0.12);
|
||||||
|
color: var(--color-success);
|
||||||
|
border: 1px solid rgba(52, 199, 89, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-banner--error {
|
||||||
|
background: rgba(255, 59, 48, 0.1);
|
||||||
|
color: var(--color-danger);
|
||||||
|
border: 1px solid rgba(255, 59, 48, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Sections
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0 0 10px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Cards
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Benutzerinfo
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-user-info__name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-user-info__username {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Avatar
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-avatar--sm {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Formulare
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-danger);
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(255, 59, 48, 0.08);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input--color {
|
||||||
|
padding: 4px 8px;
|
||||||
|
height: 44px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Sync-Karten
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-sync-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-logo--google {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-logo--apple {
|
||||||
|
background: #1c1c1e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-info__name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-info__status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-info__status--connected {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Familienmitglieder
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-members {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-member {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-member__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-member__name {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-member__meta {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-add-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Abmelden
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-logout-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Dark Mode
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.settings-sync-logo--google {
|
||||||
|
background: #2c2c2e;
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ const APP_SHELL = [
|
|||||||
'/styles/notes.css',
|
'/styles/notes.css',
|
||||||
'/styles/contacts.css',
|
'/styles/contacts.css',
|
||||||
'/styles/budget.css',
|
'/styles/budget.css',
|
||||||
|
'/styles/settings.css',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,39 @@ router.post('/users', requireAuth, requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/auth/me/password
|
||||||
|
* Ändert das eigene Passwort.
|
||||||
|
* Body: { current_password: string, new_password: string }
|
||||||
|
* Response: { ok: true }
|
||||||
|
*/
|
||||||
|
router.patch('/me/password', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { current_password, new_password } = req.body;
|
||||||
|
|
||||||
|
if (!current_password || !new_password) {
|
||||||
|
return res.status(400).json({ error: 'Aktuelles und neues Passwort erforderlich.', code: 400 });
|
||||||
|
}
|
||||||
|
if (new_password.length < 8) {
|
||||||
|
return res.status(400).json({ error: 'Neues Passwort muss mindestens 8 Zeichen haben.', code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = db.get().prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.userId);
|
||||||
|
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden.', code: 404 });
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(current_password, user.password_hash);
|
||||||
|
if (!valid) return res.status(401).json({ error: 'Aktuelles Passwort falsch.', code: 401 });
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(new_password, 12);
|
||||||
|
db.get().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.session.userId);
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Passwort-Ändern-Fehler:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/v1/auth/users/:id
|
* DELETE /api/v1/auth/users/:id
|
||||||
* Admin only. Löscht ein Familienmitglied.
|
* Admin only. Löscht ein Familienmitglied.
|
||||||
|
|||||||
@@ -172,6 +172,14 @@ const MIGRATIONS_SQL = {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
|
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
|
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
|
||||||
`,
|
`,
|
||||||
|
2: `
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id);
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { MIGRATIONS_SQL };
|
module.exports = { MIGRATIONS_SQL };
|
||||||
|
|||||||
+13
-2
@@ -265,8 +265,19 @@ const MIGRATIONS = [
|
|||||||
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
|
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
// Zukünftige Migrations hier anhängen:
|
{
|
||||||
// { version: 2, description: '...', up: '...' },
|
version: 2,
|
||||||
|
description: 'Sync-Konfigurationstabelle für Google/Apple Calendar',
|
||||||
|
up: `
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+28
-1
@@ -11,9 +11,11 @@ const express = require('express');
|
|||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
const { router: authRouter, sessionMiddleware, requireAuth } = require('./auth');
|
const { router: authRouter, sessionMiddleware, requireAuth } = require('./auth');
|
||||||
const { csrfMiddleware } = require('./middleware/csrf');
|
const { csrfMiddleware } = require('./middleware/csrf');
|
||||||
|
const googleCalendar = require('./services/google-calendar');
|
||||||
|
const appleCalendar = require('./services/apple-calendar');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -154,12 +156,37 @@ app.use((err, req, res, _next) => {
|
|||||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Auto-Sync Scheduler (Google + Apple Calendar)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
const SYNC_INTERVAL_MS = (parseInt(process.env.SYNC_INTERVAL_MINUTES, 10) || 15) * 60_000;
|
||||||
|
|
||||||
|
async function runSync() {
|
||||||
|
const { connected: googleConnected } = googleCalendar.getStatus();
|
||||||
|
if (googleConnected) {
|
||||||
|
googleCalendar.sync().catch((e) => console.error('[Sync] Google Fehler:', e.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { configured: appleConfigured } = appleCalendar.getStatus();
|
||||||
|
if (appleConfigured) {
|
||||||
|
appleCalendar.sync().catch((e) => console.error('[Sync] Apple Fehler:', e.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Server starten
|
// Server starten
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[Oikos] Server läuft auf Port ${PORT}`);
|
console.log(`[Oikos] Server läuft auf Port ${PORT}`);
|
||||||
console.log(`[Oikos] Umgebung: ${process.env.NODE_ENV || 'development'}`);
|
console.log(`[Oikos] Umgebung: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
|
||||||
|
// Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert)
|
||||||
|
setTimeout(() => {
|
||||||
|
runSync();
|
||||||
|
setInterval(runSync, SYNC_INTERVAL_MS);
|
||||||
|
console.log(`[Sync] Auto-Sync alle ${SYNC_INTERVAL_MS / 60_000} Minuten aktiv.`);
|
||||||
|
}, 10_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|||||||
+126
-3
@@ -7,9 +7,12 @@
|
|||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
|
const googleCalendar = require('../services/google-calendar');
|
||||||
|
const appleCalendar = require('../services/apple-calendar');
|
||||||
|
const { requireAdmin } = require('../auth');
|
||||||
|
|
||||||
const VALID_SOURCES = ['local', 'google', 'apple'];
|
const VALID_SOURCES = ['local', 'google', 'apple'];
|
||||||
const DATETIME_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?Z?)?$/;
|
const DATETIME_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?Z?)?$/;
|
||||||
@@ -100,6 +103,126 @@ router.get('/upcoming', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Google Calendar Sync-Routen
|
||||||
|
// Alle vor /:id registriert, um Konflikte zu vermeiden.
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/calendar/google/auth
|
||||||
|
* Admin only. Leitet zum Google OAuth-Consent-Screen weiter.
|
||||||
|
*/
|
||||||
|
router.get('/google/auth', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = googleCalendar.getAuthUrl();
|
||||||
|
if (!url) return res.status(503).json({ error: 'Google nicht konfiguriert.', code: 503 });
|
||||||
|
res.redirect(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/google/auth]', err);
|
||||||
|
res.status(503).json({ error: err.message, code: 503 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/calendar/google/callback
|
||||||
|
* OAuth-Callback von Google. Tauscht Code gegen Tokens und startet initialen Sync.
|
||||||
|
* Query: ?code=...
|
||||||
|
*/
|
||||||
|
router.get('/google/callback', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { code, error } = req.query;
|
||||||
|
if (error) return res.redirect('/settings?sync_error=google');
|
||||||
|
if (!code) return res.status(400).json({ error: 'Kein Code erhalten.', code: 400 });
|
||||||
|
|
||||||
|
await googleCalendar.handleCallback(code);
|
||||||
|
|
||||||
|
// Initialen Sync im Hintergrund starten (kein await — Redirect soll sofort erfolgen)
|
||||||
|
googleCalendar.sync().catch((e) => console.error('[Google] Initialer Sync fehlgeschlagen:', e.message));
|
||||||
|
|
||||||
|
res.redirect('/settings?sync_ok=google');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/google/callback]', err);
|
||||||
|
res.redirect('/settings?sync_error=google');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/calendar/google/sync
|
||||||
|
* Manueller Sync-Trigger.
|
||||||
|
* Response: { ok: true, lastSync: string }
|
||||||
|
*/
|
||||||
|
router.post('/google/sync', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await googleCalendar.sync();
|
||||||
|
const { lastSync } = googleCalendar.getStatus();
|
||||||
|
res.json({ ok: true, lastSync });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/google/sync]', err);
|
||||||
|
res.status(500).json({ error: err.message, code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/calendar/google/status
|
||||||
|
* Response: { configured, connected, lastSync }
|
||||||
|
*/
|
||||||
|
router.get('/google/status', (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(googleCalendar.getStatus());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/google/status]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/calendar/google/disconnect
|
||||||
|
* Admin only. Tokens löschen und Verbindung trennen.
|
||||||
|
* Response: { ok: true }
|
||||||
|
*/
|
||||||
|
router.delete('/google/disconnect', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
googleCalendar.disconnect();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/google/disconnect]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Apple Calendar Sync-Routen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/calendar/apple/status
|
||||||
|
* Response: { configured, lastSync }
|
||||||
|
*/
|
||||||
|
router.get('/apple/status', (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(appleCalendar.getStatus());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/apple/status]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/calendar/apple/sync
|
||||||
|
* Manueller Sync-Trigger.
|
||||||
|
* Response: { ok: true, lastSync: string }
|
||||||
|
*/
|
||||||
|
router.post('/apple/sync', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await appleCalendar.sync();
|
||||||
|
const { lastSync } = appleCalendar.getStatus();
|
||||||
|
res.json({ ok: true, lastSync });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/apple/sync]', err);
|
||||||
|
res.status(500).json({ error: err.message, code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// GET /api/v1/calendar/:id
|
// GET /api/v1/calendar/:id
|
||||||
// Einzelnen Termin abrufen.
|
// Einzelnen Termin abrufen.
|
||||||
|
|||||||
@@ -1,11 +1,292 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Apple Calendar Sync (CalDAV)
|
* Modul: Apple Calendar Sync (CalDAV)
|
||||||
* Zweck: Bidirektionaler Sync mit iCloud Calendar via CalDAV-Protokoll
|
* Zweck: Bidirektionaler Sync mit iCloud Calendar via CalDAV-Protokoll
|
||||||
* Abhängigkeiten: tsdav, server/db.js
|
* Abhängigkeiten: tsdav (ESM — dynamisch importiert), server/db.js
|
||||||
|
*
|
||||||
|
* Konfiguration (.env):
|
||||||
|
* APPLE_CALDAV_URL — z.B. https://caldav.icloud.com
|
||||||
|
* APPLE_USERNAME — Apple-ID E-Mail
|
||||||
|
* APPLE_APP_SPECIFIC_PASSWORD — App-spezifisches Passwort aus appleid.apple.com
|
||||||
|
*
|
||||||
|
* sync_config-Schlüssel:
|
||||||
|
* apple_last_sync — ISO-8601-Timestamp des letzten Syncs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Platzhalter — wird in Phase 3 implementiert
|
'use strict';
|
||||||
|
|
||||||
module.exports = {
|
const db = require('../db');
|
||||||
sync: async () => null,
|
|
||||||
};
|
const APPLE_COLOR = '#FC3C44';
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// sync_config Helfer
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function cfgGet(key) {
|
||||||
|
const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get(key);
|
||||||
|
return row ? row.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cfgSet(key, value) {
|
||||||
|
db.get().prepare(`
|
||||||
|
INSERT INTO sync_config (key, value)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
`).run(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Verbindungsstatus
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function getStatus() {
|
||||||
|
const configured = !!(
|
||||||
|
process.env.APPLE_CALDAV_URL &&
|
||||||
|
process.env.APPLE_USERNAME &&
|
||||||
|
process.env.APPLE_APP_SPECIFIC_PASSWORD
|
||||||
|
);
|
||||||
|
const lastSync = cfgGet('apple_last_sync');
|
||||||
|
return { configured, lastSync };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Minimaler ICS-Parser
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfaltet ICS-Zeilenfortsetzungen (RFC 5545 §3.1).
|
||||||
|
* @param {string} ics
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function unfoldLines(ics) {
|
||||||
|
return ics.replace(/\r?\n[ \t]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert alle VEVENT-Blöcke aus einem ICS-String.
|
||||||
|
* @param {string} ics
|
||||||
|
* @returns {Array<{uid, summary, description, location, dtstart, dtend, rrule, allDay}>}
|
||||||
|
*/
|
||||||
|
function parseICS(ics) {
|
||||||
|
const unfolded = unfoldLines(ics);
|
||||||
|
const events = [];
|
||||||
|
const vEventRe = /BEGIN:VEVENT([\s\S]*?)END:VEVENT/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = vEventRe.exec(unfolded)) !== null) {
|
||||||
|
const block = match[1];
|
||||||
|
const get = (prop) => {
|
||||||
|
const re = new RegExp(`^${prop}(?:;[^:]*)?:(.*)$`, 'im');
|
||||||
|
const m = re.exec(block);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uid = get('UID');
|
||||||
|
const summary = get('SUMMARY') || '(kein Titel)';
|
||||||
|
const description = get('DESCRIPTION') || null;
|
||||||
|
const location = get('LOCATION') || null;
|
||||||
|
const rrule = get('RRULE') ? `RRULE:${get('RRULE')}` : null;
|
||||||
|
|
||||||
|
// DTSTART — mit optionalem TZID oder VALUE=DATE
|
||||||
|
const dtStartRaw = (() => {
|
||||||
|
const m = /^DTSTART(?:;[^:]*)?:(.*)$/im.exec(block);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
})();
|
||||||
|
const dtEndRaw = (() => {
|
||||||
|
const m = /^DTEND(?:;[^:]*)?:(.*)$/im.exec(block);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const allDay = /^DTSTART;VALUE=DATE:/im.test(block);
|
||||||
|
const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay) : null;
|
||||||
|
const dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay) : null;
|
||||||
|
|
||||||
|
if (!uid || !dtstart) continue;
|
||||||
|
|
||||||
|
events.push({ uid, summary, description, location, dtstart, dtend, rrule, allDay });
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konvertiert ICS-Datumswert in ISO-8601-String.
|
||||||
|
* Unterstützt: DATE (20240101), DATE-TIME lokal (20240101T120000),
|
||||||
|
* DATE-TIME UTC (20240101T120000Z), DATE-TIME mit TZID (ignoriert TZID, behandelt als lokal).
|
||||||
|
* @param {string} val
|
||||||
|
* @param {boolean} allDay
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatICSDate(val, allDay) {
|
||||||
|
if (allDay || /^\d{8}$/.test(val)) {
|
||||||
|
// DATE: YYYYMMDD → YYYY-MM-DD
|
||||||
|
return `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6, 8)}`;
|
||||||
|
}
|
||||||
|
// DATE-TIME: YYYYMMDDTHHMMSS[Z]
|
||||||
|
const y = val.slice(0, 4);
|
||||||
|
const mo = val.slice(4, 6);
|
||||||
|
const d = val.slice(6, 8);
|
||||||
|
const h = val.slice(9, 11);
|
||||||
|
const mi = val.slice(11, 13);
|
||||||
|
const s = val.slice(13, 15) || '00';
|
||||||
|
const z = val.endsWith('Z') ? 'Z' : '';
|
||||||
|
return `${y}-${mo}-${d}T${h}:${mi}:${s}${z}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Minimaler ICS-Builder
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen minimalen ICS-String für ein lokales Event.
|
||||||
|
* @param {{ id, title, description, start_datetime, end_datetime, all_day, location, recurrence_rule }} event
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildICS(event) {
|
||||||
|
const uid = `oikos-${event.id}@oikos.local`;
|
||||||
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
const lines = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Oikos//Familienplaner//DE',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${uid}`,
|
||||||
|
`DTSTAMP:${now}`,
|
||||||
|
`SUMMARY:${escapeICS(event.title)}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (event.all_day) {
|
||||||
|
const startDate = event.start_datetime.slice(0, 10).replace(/-/g, '');
|
||||||
|
const endDate = (event.end_datetime || event.start_datetime).slice(0, 10).replace(/-/g, '');
|
||||||
|
lines.push(`DTSTART;VALUE=DATE:${startDate}`);
|
||||||
|
lines.push(`DTEND;VALUE=DATE:${endDate}`);
|
||||||
|
} else {
|
||||||
|
const startDt = event.start_datetime.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
const endDt = (event.end_datetime || event.start_datetime).replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
lines.push(`DTSTART:${startDt}`);
|
||||||
|
lines.push(`DTEND:${endDt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.description) lines.push(`DESCRIPTION:${escapeICS(event.description)}`);
|
||||||
|
if (event.location) lines.push(`LOCATION:${escapeICS(event.location)}`);
|
||||||
|
if (event.recurrence_rule) lines.push(event.recurrence_rule); // z.B. RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||||
|
|
||||||
|
lines.push('END:VEVENT', 'END:VCALENDAR');
|
||||||
|
return lines.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeICS(str) {
|
||||||
|
return String(str).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Sync
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bidirektionaler CalDAV-Sync mit iCloud.
|
||||||
|
* Inbound: iCloud → lokale DB (Upsert via external_calendar_id = UID)
|
||||||
|
* Outbound: lokale Termine (external_source='local', external_calendar_id IS NULL) → iCloud
|
||||||
|
*/
|
||||||
|
async function sync() {
|
||||||
|
const caldavUrl = process.env.APPLE_CALDAV_URL;
|
||||||
|
const username = process.env.APPLE_USERNAME;
|
||||||
|
const password = process.env.APPLE_APP_SPECIFIC_PASSWORD;
|
||||||
|
|
||||||
|
if (!caldavUrl || !username || !password) {
|
||||||
|
throw new Error('[Apple] APPLE_CALDAV_URL, APPLE_USERNAME und APPLE_APP_SPECIFIC_PASSWORD müssen gesetzt sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// tsdav ist ESM-only — dynamischer Import aus CommonJS
|
||||||
|
const { createDAVClient } = await import('tsdav');
|
||||||
|
|
||||||
|
const client = await createDAVClient({
|
||||||
|
serverUrl: caldavUrl,
|
||||||
|
credentials: { username, password },
|
||||||
|
authMethod: 'Basic',
|
||||||
|
defaultAccountType: 'caldav',
|
||||||
|
});
|
||||||
|
|
||||||
|
const calendars = await client.fetchCalendars();
|
||||||
|
if (!calendars.length) {
|
||||||
|
console.warn('[Apple] Keine Kalender gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard-Kalender: erster nicht-Geburtstags-Kalender
|
||||||
|
const cal = calendars.find((c) => !c.displayName?.toLowerCase().includes('geburts')) || calendars[0];
|
||||||
|
|
||||||
|
const calObjects = await client.fetchCalendarObjects({ calendar: cal });
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Inbound: iCloud → lokal
|
||||||
|
// --------------------------------------------------------
|
||||||
|
for (const obj of calObjects) {
|
||||||
|
const parsed = parseICS(obj.data || '');
|
||||||
|
for (const ev of parsed) {
|
||||||
|
try {
|
||||||
|
const existing = db.get().prepare(
|
||||||
|
`SELECT id FROM calendar_events WHERE external_calendar_id = ? AND external_source = 'apple'`
|
||||||
|
).get(ev.uid);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE calendar_events
|
||||||
|
SET title = ?, description = ?, start_datetime = ?, end_datetime = ?,
|
||||||
|
all_day = ?, location = ?, recurrence_rule = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
ev.summary, ev.description, ev.dtstart, ev.dtend,
|
||||||
|
ev.allDay ? 1 : 0, ev.location, ev.rrule, existing.id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
db.get().prepare(`
|
||||||
|
INSERT INTO calendar_events
|
||||||
|
(title, description, start_datetime, end_datetime, all_day,
|
||||||
|
location, color, external_calendar_id, external_source, recurrence_rule, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'apple', ?, 1)
|
||||||
|
`).run(
|
||||||
|
ev.summary, ev.description, ev.dtstart, ev.dtend,
|
||||||
|
ev.allDay ? 1 : 0, ev.location, APPLE_COLOR, ev.uid, ev.rrule
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Apple] Upsert-Fehler für UID ${ev.uid}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Outbound: lokal → iCloud
|
||||||
|
// --------------------------------------------------------
|
||||||
|
const localEvents = db.get().prepare(`
|
||||||
|
SELECT * FROM calendar_events
|
||||||
|
WHERE external_source = 'local' AND external_calendar_id IS NULL
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
for (const event of localEvents) {
|
||||||
|
try {
|
||||||
|
const icsData = buildICS(event);
|
||||||
|
const uid = `oikos-${event.id}@oikos.local`;
|
||||||
|
const filename = `${uid}.ics`;
|
||||||
|
|
||||||
|
await client.createCalendarObject({
|
||||||
|
calendar: cal,
|
||||||
|
filename,
|
||||||
|
iCalString: icsData,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE calendar_events SET external_calendar_id = ?, external_source = 'apple' WHERE id = ?
|
||||||
|
`).run(uid, event.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Apple] Outbound-Fehler für Event ${event.id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgSet('apple_last_sync', new Date().toISOString());
|
||||||
|
console.log(`[Apple] Sync abgeschlossen — ${calObjects.length} Objekte inbound, ${localEvents.length} lokal → iCloud.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sync, getStatus };
|
||||||
|
|||||||
@@ -2,12 +2,323 @@
|
|||||||
* Modul: Google Calendar Sync
|
* Modul: Google Calendar Sync
|
||||||
* Zweck: OAuth 2.0 + bidirektionaler Sync mit Google Calendar API v3
|
* Zweck: OAuth 2.0 + bidirektionaler Sync mit Google Calendar API v3
|
||||||
* Abhängigkeiten: googleapis, server/db.js
|
* Abhängigkeiten: googleapis, server/db.js
|
||||||
|
*
|
||||||
|
* sync_config-Schlüssel:
|
||||||
|
* google_access_token — OAuth Access Token
|
||||||
|
* google_refresh_token — OAuth Refresh Token (langlebig)
|
||||||
|
* google_token_expiry — ISO-8601-Timestamp bis wann Access Token gültig ist
|
||||||
|
* google_sync_token — Inkrementeller Sync-Token von Google (events.list)
|
||||||
|
* google_last_sync — ISO-8601-Timestamp des letzten erfolgreichen Syncs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Platzhalter — wird in Phase 3 implementiert
|
'use strict';
|
||||||
|
|
||||||
module.exports = {
|
const { google } = require('googleapis');
|
||||||
getAuthUrl: () => null,
|
const db = require('../db');
|
||||||
handleCallback: async () => null,
|
|
||||||
sync: async () => null,
|
const GOOGLE_COLOR = '#4285F4';
|
||||||
};
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// OAuth2-Client (lazy initialisiert)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function createClient() {
|
||||||
|
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||||
|
const redirectUri = process.env.GOOGLE_REDIRECT_URI;
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret || !redirectUri) {
|
||||||
|
throw new Error('[Google] GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET und GOOGLE_REDIRECT_URI müssen gesetzt sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// sync_config Helfer
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function cfgGet(key) {
|
||||||
|
const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get(key);
|
||||||
|
return row ? row.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cfgSet(key, value) {
|
||||||
|
db.get().prepare(`
|
||||||
|
INSERT INTO sync_config (key, value)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
`).run(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cfgDel(key) {
|
||||||
|
db.get().prepare('DELETE FROM sync_config WHERE key = ?').run(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Client mit gespeicherten Tokens laden
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function loadAuthorizedClient() {
|
||||||
|
const accessToken = cfgGet('google_access_token');
|
||||||
|
const refreshToken = cfgGet('google_refresh_token');
|
||||||
|
|
||||||
|
if (!accessToken || !refreshToken) {
|
||||||
|
throw new Error('[Google] Nicht konfiguriert — zuerst OAuth durchführen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createClient();
|
||||||
|
client.setCredentials({
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
expiry_date: cfgGet('google_token_expiry') ? parseInt(cfgGet('google_token_expiry'), 10) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token-Refresh automatisch speichern
|
||||||
|
client.on('tokens', (tokens) => {
|
||||||
|
if (tokens.access_token) cfgSet('google_access_token', tokens.access_token);
|
||||||
|
if (tokens.expiry_date) cfgSet('google_token_expiry', String(tokens.expiry_date));
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Öffentliche API
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generiert die Google OAuth2-URL zum Weiterleiten des Admins.
|
||||||
|
* @returns {string} Auth-URL
|
||||||
|
*/
|
||||||
|
function getAuthUrl() {
|
||||||
|
const client = createClient();
|
||||||
|
return client.generateAuthUrl({
|
||||||
|
access_type: 'offline',
|
||||||
|
prompt: 'consent',
|
||||||
|
scope: ['https://www.googleapis.com/auth/calendar'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth-Callback: tauscht Code gegen Tokens, speichert in sync_config.
|
||||||
|
* @param {string} code — Code aus dem OAuth-Callback-Query-Parameter
|
||||||
|
*/
|
||||||
|
async function handleCallback(code) {
|
||||||
|
const client = createClient();
|
||||||
|
const { tokens } = await client.getToken(code);
|
||||||
|
|
||||||
|
if (!tokens.refresh_token) {
|
||||||
|
throw new Error('[Google] Kein Refresh Token erhalten. Bitte Zugriff in Google-Konto widerrufen und erneut verbinden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgSet('google_access_token', tokens.access_token);
|
||||||
|
cfgSet('google_refresh_token', tokens.refresh_token);
|
||||||
|
if (tokens.expiry_date) cfgSet('google_token_expiry', String(tokens.expiry_date));
|
||||||
|
|
||||||
|
console.log('[Google] OAuth erfolgreich — Tokens gespeichert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindungsstatus zurückgeben.
|
||||||
|
* @returns {{ configured: boolean, connected: boolean, lastSync: string|null }}
|
||||||
|
*/
|
||||||
|
function getStatus() {
|
||||||
|
const configured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.GOOGLE_REDIRECT_URI);
|
||||||
|
const connected = !!(cfgGet('google_access_token') && cfgGet('google_refresh_token'));
|
||||||
|
const lastSync = cfgGet('google_last_sync');
|
||||||
|
return { configured, connected, lastSync };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens und Sync-State löschen (Verbindung trennen).
|
||||||
|
*/
|
||||||
|
function disconnect() {
|
||||||
|
['google_access_token', 'google_refresh_token', 'google_token_expiry',
|
||||||
|
'google_sync_token', 'google_last_sync'].forEach(cfgDel);
|
||||||
|
console.log('[Google] Verbindung getrennt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bidirektionaler Sync.
|
||||||
|
* Inbound: Google → lokale DB (Upsert via external_calendar_id)
|
||||||
|
* Outbound: lokale Termine (external_source='local', external_calendar_id IS NULL) → Google
|
||||||
|
*/
|
||||||
|
async function sync() {
|
||||||
|
const client = loadAuthorizedClient();
|
||||||
|
const calendar = google.calendar({ version: 'v3', auth: client });
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Inbound: Google → lokal
|
||||||
|
// --------------------------------------------------------
|
||||||
|
let syncToken = cfgGet('google_sync_token');
|
||||||
|
let pageToken = undefined;
|
||||||
|
let newSyncToken = null;
|
||||||
|
|
||||||
|
do {
|
||||||
|
let listParams = {
|
||||||
|
calendarId: 'primary',
|
||||||
|
singleEvents: true,
|
||||||
|
pageToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (syncToken) {
|
||||||
|
listParams.syncToken = syncToken;
|
||||||
|
} else {
|
||||||
|
// Erstsync: letzte 3 Monate + nächste 12 Monate
|
||||||
|
const timeMin = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const timeMax = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
listParams.timeMin = timeMin;
|
||||||
|
listParams.timeMax = timeMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await calendar.events.list(listParams);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 410) {
|
||||||
|
// syncToken abgelaufen → vollständiger Resync
|
||||||
|
console.warn('[Google] syncToken ungültig — vollständiger Resync.');
|
||||||
|
cfgDel('google_sync_token');
|
||||||
|
syncToken = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = response.data.items || [];
|
||||||
|
upsertGoogleEvents(items);
|
||||||
|
|
||||||
|
pageToken = response.data.nextPageToken;
|
||||||
|
newSyncToken = response.data.nextSyncToken || newSyncToken;
|
||||||
|
} while (pageToken);
|
||||||
|
|
||||||
|
if (newSyncToken) cfgSet('google_sync_token', newSyncToken);
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Outbound: lokal → Google
|
||||||
|
// --------------------------------------------------------
|
||||||
|
const localEvents = db.get().prepare(`
|
||||||
|
SELECT * FROM calendar_events
|
||||||
|
WHERE external_source = 'local' AND external_calendar_id IS NULL
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
for (const event of localEvents) {
|
||||||
|
try {
|
||||||
|
const gEvent = localEventToGoogle(event);
|
||||||
|
const created = await calendar.events.insert({
|
||||||
|
calendarId: 'primary',
|
||||||
|
requestBody: gEvent,
|
||||||
|
});
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE calendar_events SET external_calendar_id = ?, external_source = 'google' WHERE id = ?
|
||||||
|
`).run(created.data.id, event.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Google] Outbound-Fehler für Event ${event.id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgSet('google_last_sync', new Date().toISOString());
|
||||||
|
console.log(`[Google] Sync abgeschlossen — ${localEvents.length} lokal → Google, Inbound via syncToken.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Helfer: Google-Event in lokale DB upserten
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function upsertGoogleEvents(items) {
|
||||||
|
const upsert = db.get().prepare(`
|
||||||
|
INSERT INTO calendar_events
|
||||||
|
(title, description, start_datetime, end_datetime, all_day,
|
||||||
|
location, color, external_calendar_id, external_source, recurrence_rule, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'google', ?, 1)
|
||||||
|
ON CONFLICT(external_calendar_id) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
description = excluded.description,
|
||||||
|
start_datetime = excluded.start_datetime,
|
||||||
|
end_datetime = excluded.end_datetime,
|
||||||
|
all_day = excluded.all_day,
|
||||||
|
location = excluded.location,
|
||||||
|
recurrence_rule = excluded.recurrence_rule
|
||||||
|
`);
|
||||||
|
|
||||||
|
const del = db.get().prepare(`
|
||||||
|
DELETE FROM calendar_events WHERE external_calendar_id = ? AND external_source = 'google'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Erst external_calendar_id UNIQUE index anlegen falls noch nicht vorhanden
|
||||||
|
// (Migration 2 legt idx_calendar_external_id an, aber kein UNIQUE constraint)
|
||||||
|
// Wir nutzen stattdessen manuelles Upsert mit SELECT + INSERT/UPDATE
|
||||||
|
const insertOrUpdate = db.transaction((item) => {
|
||||||
|
if (item.status === 'cancelled') {
|
||||||
|
del.run(item.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDay = !!(item.start?.date && !item.start?.dateTime);
|
||||||
|
const startDt = allDay ? item.start.date : (item.start?.dateTime || item.start?.date);
|
||||||
|
const endDt = allDay ? (item.end?.date || null) : (item.end?.dateTime || item.end?.date || null);
|
||||||
|
const title = item.summary || '(kein Titel)';
|
||||||
|
const description = item.description || null;
|
||||||
|
const location = item.location || null;
|
||||||
|
const rrule = item.recurrence ? item.recurrence[0] : null;
|
||||||
|
|
||||||
|
const existing = db.get().prepare(
|
||||||
|
'SELECT id FROM calendar_events WHERE external_calendar_id = ? AND external_source = ?'
|
||||||
|
).get(item.id, 'google');
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE calendar_events
|
||||||
|
SET title = ?, description = ?, start_datetime = ?, end_datetime = ?,
|
||||||
|
all_day = ?, location = ?, recurrence_rule = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(title, description, startDt, endDt, allDay ? 1 : 0, location, rrule, existing.id);
|
||||||
|
} else {
|
||||||
|
db.get().prepare(`
|
||||||
|
INSERT INTO calendar_events
|
||||||
|
(title, description, start_datetime, end_datetime, all_day,
|
||||||
|
location, color, external_calendar_id, external_source, recurrence_rule, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'google', ?, 1)
|
||||||
|
`).run(title, description, startDt, endDt, allDay ? 1 : 0, location, GOOGLE_COLOR, item.id, rrule);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
insertOrUpdate(item);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Google] Upsert-Fehler für Event ${item.id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Helfer: lokales Event → Google Calendar Event Format
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function localEventToGoogle(event) {
|
||||||
|
const allDay = !!event.all_day;
|
||||||
|
const gEvent = {
|
||||||
|
summary: event.title,
|
||||||
|
description: event.description || undefined,
|
||||||
|
location: event.location || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (allDay) {
|
||||||
|
gEvent.start = { date: event.start_datetime.slice(0, 10) };
|
||||||
|
gEvent.end = { date: event.end_datetime ? event.end_datetime.slice(0, 10) : event.start_datetime.slice(0, 10) };
|
||||||
|
} else {
|
||||||
|
gEvent.start = { dateTime: event.start_datetime, timeZone: 'Europe/Berlin' };
|
||||||
|
gEvent.end = { dateTime: event.end_datetime || event.start_datetime, timeZone: 'Europe/Berlin' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.recurrence_rule) {
|
||||||
|
gEvent.recurrence = [event.recurrence_rule];
|
||||||
|
}
|
||||||
|
|
||||||
|
return gEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getAuthUrl, handleCallback, getStatus, disconnect, sync };
|
||||||
|
|||||||
Reference in New Issue
Block a user