feat: add WannPassts booking app with Go backend and Vue frontend
Initial project setup for a privacy-focused, self-hosted scheduling app: - Go backend with JWT auth, SQLite storage, and chi router - Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links - Public booking pages with configurable slots and booking requests - AES-256-GCM encryption for stored provider tokens - Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router - Docs, .gitignore, and .env.example for local development
This commit is contained in:
commit
cb30223fd4
47 changed files with 6599 additions and 0 deletions
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# ─── Node / Frontend ────────────────────────────────────────────────
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
*.local
|
||||||
|
npm-debug.log*
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# ─── Go / Backend ───────────────────────────────────────────────────
|
||||||
|
backend/cmd/server/server
|
||||||
|
backend/server
|
||||||
|
backend/wannpassts-server
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.out
|
||||||
|
|
||||||
|
# ─── SQLite-Datenbanken (Nutzerdaten!) ──────────────────────────────
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# ─── Geheimnisse & Konfiguration ────────────────────────────────────
|
||||||
|
# .env enthält Secrets – .env.example bleibt versioniert.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# ─── Editoren & Betriebssysteme ─────────────────────────────────────
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# ─── Sonstiges ──────────────────────────────────────────────────────
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
132
README.md
Normal file
132
README.md
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
# WannPassts
|
||||||
|
|
||||||
|
**Deine freien Zeiten – geteilt per Link. Ohne deine Termine preiszugeben.**
|
||||||
|
|
||||||
|
WannPassts ist eine Self-Hosted-Buchungs-App (à la Cal.com / Calendly, aber privat):
|
||||||
|
Du verbindest deine Kalender, teilst einen Link, und andere sehen nur **frei / belegt**
|
||||||
|
und stellen Buchungsanfragen. Termintitel, Orte und Beschreibungen verlassen deine
|
||||||
|
Kalender nie.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔐 **Registrierung/Login** (JWT, bcrypt-Passwort-Hashes)
|
||||||
|
- 📆 **Kalender-Connect:**
|
||||||
|
- **Google Kalender** per OAuth – nutzt die FreeBusy-API, die *strukturell* keine Termindetails liefert
|
||||||
|
- **iCloud** & andere (Fastmail, Nextcloud, …) per **CalDAV** – iCloud mit App-spezifischem Passwort
|
||||||
|
- **Beliebige ICS-/webcal-Links** (z. B. Google-„Private Adresse“, Ferien-/Schichtpläne)
|
||||||
|
- 🔗 **Öffentliche Buchungsseite** (`/b/<slug>`): freie Slots im von dir definierten Zeitfenster, Wochentage, Slot-Raster und Dauern – **niemals Termindetails**
|
||||||
|
- 📨 **Buchungsanfragen** annehmen/ablehnen; angenommene Zeiten werden belegt und verhindern Doppelbuchungen
|
||||||
|
- ⚙️ Buchungsregeln: Zeitfenster, Slot-Raster (10–60 Min), Dauern, Wochentage, Horizont (1–120 Tage), Zeitzone
|
||||||
|
- 🧊 **Liquid-Glass-Dark-UI** (Glassmorphism, animierte Orbs), komplett auf Deutsch
|
||||||
|
- 🔒 Provider-Tokens werden **AES-256-GCM-verschlüsselt** in der SQLite-DB gespeichert
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Teil | Technologie |
|
||||||
|
| -------- | ------------------------------------------------------------------ |
|
||||||
|
| Frontend | Vue 3 + TypeScript, Vite, Pinia, Vue Router (kein UI-Framework) |
|
||||||
|
| Backend | Go, chi, JWT, SQLite (modernc, CGO-frei), x/oauth2, go-webdav |
|
||||||
|
|
||||||
|
## Schnellstart (Entwicklung)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 – Backend (http://localhost:8080)
|
||||||
|
cd backend
|
||||||
|
cp .env.example .env # optional anpassen; ohne .env läuft es mit Defaults
|
||||||
|
go run ./cmd/server
|
||||||
|
|
||||||
|
# Terminal 2 – Frontend (http://localhost:5173, Proxied /api → 8080)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann http://localhost:5173 öffnen, Konto erstellen, Kalender verbinden, Link teilen. 🎉
|
||||||
|
|
||||||
|
## Produktion (ein einziger Go-Prozess)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build && cd ..
|
||||||
|
cd backend
|
||||||
|
STATIC_DIR=../frontend/dist \
|
||||||
|
APP_URL=https://deine-domain.de \
|
||||||
|
FRONTEND_URL=https://deine-domain.de \
|
||||||
|
JWT_SECRET=$(openssl rand -hex 32) \
|
||||||
|
ENCRYPTION_KEY=$(openssl rand -hex 32) \
|
||||||
|
go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Go-Server liefert dann das Frontend (`dist/`) **und** die API über einen Port.
|
||||||
|
|
||||||
|
## Kalender-Anbindung einrichten
|
||||||
|
|
||||||
|
### Google
|
||||||
|
1. [Google Cloud Console](https://console.cloud.google.com): Projekt anlegen, **Google Calendar API** aktivieren.
|
||||||
|
2. OAuth-Zustimmungsbildschirm (External) einrichten, Scope `calendar.readonly`.
|
||||||
|
3. **OAuth-Client-ID** (Webanwendung) erstellen; als autorisierte Redirect-URI
|
||||||
|
`{APP_URL}/api/calendars/google/callback` eintragen (lokal: `http://localhost:8080/api/calendars/google/callback`).
|
||||||
|
4. `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET` in `backend/.env` setzen.
|
||||||
|
|
||||||
|
Beim Verbinden wird nur der **Primärkalender** angebunden; gelesen wird ausschließlich Free/Busy.
|
||||||
|
|
||||||
|
### iCloud
|
||||||
|
1. Auf [appleid.apple.com](https://appleid.apple.com/account/manage) → *Anmeldung und Sicherheit* → **App-spezifisches Passwort** erzeugen.
|
||||||
|
2. In WannPassts: *Kalender → iCloud / CalDAV*:
|
||||||
|
- Server: `https://caldav.icloud.com`
|
||||||
|
- Benutzername: Apple-ID (E-Mail)
|
||||||
|
- Passwort: das App-spezifische Passwort (nicht das normale!)
|
||||||
|
3. Der erste Kalender mit Terminen wird automatisch erkannt (Kalender-Pfad ist optional manuell überschreibbar).
|
||||||
|
|
||||||
|
### Andere (Fastmail, Nextcloud, GMX, …)
|
||||||
|
Gleiche CalDAV-Maske mit dem jeweiligen CalDAV-Server des Anbieters, oder beliebige **ICS-Links** abonnieren.
|
||||||
|
|
||||||
|
## Datenschutz-Design
|
||||||
|
|
||||||
|
- Besucher sehen ausschließlich **Start/Ende von busy-Zeiträumen** – die API hat gar keine Felder für Titel & Co.
|
||||||
|
- Google: FreeBusy-API liefert von sich aus nur Belegungszeiträume.
|
||||||
|
- CalDAV/ICS: Termine werden serverseitig auf `DTSTART/DTEND` reduziert, abgesagte und „Verfügbar“-Termine (TRANSPARENT) werden ignoriert.
|
||||||
|
- Tokens/Passwörter der Verbindungen liegen verschlüsselt (AES-GCM aus `ENCRYPTION_KEY`) in der DB.
|
||||||
|
- Sync erfolgt bedarfsgesteuert (max. alle 5 Min) beim Aufruf der Buchungsseite plus manueller Button.
|
||||||
|
|
||||||
|
## Bekannte Grenzen (MVP)
|
||||||
|
|
||||||
|
- **Kein E-Mail-Versand** – Anfragen/Freigaben erscheinen nur im Dashboard (Mail-Versand via SMTP wäre der nächste Ausbauschritt).
|
||||||
|
- **ICS-Abos** expandieren Serientermine nicht**; CalDAV und Google tun dies serverseitig korrekt.
|
||||||
|
- **Ausstehende (pending) Anfragen blockieren noch keine Zeit** – erst angenommene. Bei zwei parallelen Anfragen für denselben Slot gewinnt, wer zuerst angenommen wird (Kollision wird beim Annehmen geprüft).
|
||||||
|
- Ein Google-Konto verbindet aktuell den Primärkalender; weitere Kalender lassen sich als ICS-Abo hinzufügen.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
cmd/server/ # Einstieg
|
||||||
|
internal/config/ # Env-/Secret-Handling
|
||||||
|
internal/db/ # SQLite-Schema & Queries
|
||||||
|
internal/crypto/ # AES-GCM für gespeicherte Provider-Tokens
|
||||||
|
internal/calendar/ # Provider: google.go, caldav.go, ics.go (+ Syncer)
|
||||||
|
internal/httpapi/ # Router, Middleware, Handler (auth, calendars, bookings, public)
|
||||||
|
frontend/
|
||||||
|
src/lib/ # api-Client, Typen, Zeitzonen-Helfer
|
||||||
|
src/stores/ # Pinia: auth, toast
|
||||||
|
src/views/ # Landing, Login, Register, Dashboard, Booking (/b/:slug)
|
||||||
|
src/components/ # GlassCard, ShareLink, Sektionen (Kalender/Anfragen/Einstellungen)
|
||||||
|
src/styles/main.css # Liquid-Glass-Dark-Designsystem
|
||||||
|
```
|
||||||
|
|
||||||
|
## API-Überblick
|
||||||
|
|
||||||
|
| Methode | Pfad | Auth | Zweck |
|
||||||
|
| ------------------- | --------------------------------- | ---- | ---------------------------------------- |
|
||||||
|
| POST | `/api/auth/register` \| `/login` | – | Konto / JWT |
|
||||||
|
| GET/PATCH | `/api/me` | ✅ | Profil & Buchungsregeln |
|
||||||
|
| POST | `/api/me/slug` | ✅ | Neuen Buchungslink erzeugen |
|
||||||
|
| GET/DELETE | `/api/calendars…` | ✅ | Verbinden (google/caldav/ics), verwalten, sync |
|
||||||
|
| GET | `/api/public/{slug}` | – | Buchungsseite: Regeln + busy-Zeiträume |
|
||||||
|
| POST | `/api/public/{slug}/bookings` | – | Buchungsanfrage stellen |
|
||||||
|
| GET | `/api/bookings`, `…/accept\|decline` | ✅ | Anfragen verwalten |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && go test ./... # ICS-Parser, Dauer-Handling, Interval-Clamping
|
||||||
|
```
|
||||||
33
backend/.env.example
Normal file
33
backend/.env.example
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# ─── WannPassts Backend Konfiguration ────────────────────────────────────────
|
||||||
|
# Diese Datei nach backend/.env kopieren und anpassen.
|
||||||
|
|
||||||
|
# HTTP-Port des Backends
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# SQLite-Datenbankdatei (relativ zum backend-Verzeichnis)
|
||||||
|
DB_PATH=wannpassts.db
|
||||||
|
|
||||||
|
# Öffentliche URL des Backends (wichtig für den Google-OAuth-Redirect!)
|
||||||
|
APP_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# URL des Frontends (dev: Vite, prod: gleiche Origin wie StaticDir)
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Statische Secrets – in Produktion zwingend setzen (z. B. `openssl rand -hex 32`)
|
||||||
|
# JWT_SECRET sichert Logins, ENCRYPTION_KEY verschlüsselt gespeicherte Kalender-Tokens.
|
||||||
|
JWT_SECRET=
|
||||||
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# ─── Google Kalender (OAuth) ─────────────────────────────────────────────────
|
||||||
|
# 1. https://console.cloud.google.com → Projekt anlegen
|
||||||
|
# 2. APIs & Services → „Google Calendar API“ aktivieren
|
||||||
|
# 3. OAuth-Zustimmungsbildschirm: External, Scope calendar.readonly
|
||||||
|
# 4. Credentials → OAuth-Client-ID (Webanwendung)
|
||||||
|
# Autorisierte Redirect-URI: {APP_URL}/api/calendars/google/callback
|
||||||
|
# (lokal also http://localhost:8080/api/calendars/google/callback)
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# ─── Optional: Frontend-Build direkt ausliefern (Produktion) ────────────────
|
||||||
|
# cd frontend && npm run build → STATIC_DIR=../frontend/dist
|
||||||
|
STATIC_DIR=
|
||||||
30
backend/cmd/server/main.go
Normal file
30
backend/cmd/server/main.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"wannpassts/internal/calendar"
|
||||||
|
"wannpassts/internal/config"
|
||||||
|
"wannpassts/internal/db"
|
||||||
|
"wannpassts/internal/httpapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
database, err := db.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Datenbank konnte nicht geöffnet werden: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
syncer := &calendar.Syncer{DB: database, Cfg: &cfg}
|
||||||
|
srv := httpapi.New(database, &cfg, syncer)
|
||||||
|
|
||||||
|
addr := ":" + cfg.Port
|
||||||
|
log.Printf("WannPassts Backend läuft auf http://localhost%s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, srv.Router()); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/go.mod
Normal file
29
backend/go.mod
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
module wannpassts
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
|
||||||
|
github.com/emersion/go-webdav v0.7.0
|
||||||
|
github.com/go-chi/chi/v5 v5.3.2
|
||||||
|
github.com/go-chi/cors v1.2.2
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
golang.org/x/crypto v0.55.0
|
||||||
|
golang.org/x/oauth2 v0.36.0
|
||||||
|
modernc.org/sqlite v1.57.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/teambition/rrule-go v1.8.2 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
modernc.org/libc v1.74.4 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
72
backend/go.sum
Normal file
72
backend/go.sum
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||||
|
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
|
||||||
|
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||||
|
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
|
||||||
|
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||||
|
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||||
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
|
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||||
|
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||||
|
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
||||||
|
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
126
backend/internal/calendar/caldav.go
Normal file
126
backend/internal/calendar/caldav.go
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-ical"
|
||||||
|
"github.com/emersion/go-webdav/caldav"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalDAVConnConfig deckt iCloud (https://caldav.icloud.com mit App-spezifischem
|
||||||
|
// Passwort) sowie Fastmail, Nextcloud und andere CalDAV-Server ab.
|
||||||
|
type CalDAVConnConfig struct {
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
CalendarPath string `json:"calendar_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalDAVProvider struct {
|
||||||
|
cfg CalDAVConnConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCalDAVProvider(cfg CalDAVConnConfig) *CalDAVProvider {
|
||||||
|
return &CalDAVProvider{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
type basicAuthTransport struct {
|
||||||
|
username, password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
clone := req.Clone(req.Context())
|
||||||
|
clone.SetBasicAuth(t.username, t.password)
|
||||||
|
return http.DefaultTransport.RoundTrip(clone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCalDAVClient(cfg CalDAVConnConfig) (*caldav.Client, error) {
|
||||||
|
hc := &http.Client{Transport: &basicAuthTransport{cfg.Username, cfg.Password}}
|
||||||
|
return caldav.NewClient(hc, cfg.ServerURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverCalDAV findet automatisch den ersten Kalender mit Terminen (VEVENT)
|
||||||
|
// auf dem Server. Für iCloud: Apple-ID als Benutzername und ein im Apple-ID-
|
||||||
|
// Konto erzeugtes App-spezifisches Passwort.
|
||||||
|
func DiscoverCalDAV(ctx context.Context, cfg CalDAVConnConfig) (path, name string, err error) {
|
||||||
|
c, err := newCalDAVClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("CalDAV-Server-URL ungültig: %w", err)
|
||||||
|
}
|
||||||
|
principal, err := c.FindCurrentUserPrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("CalDAV: Anmeldung/Principal fehlgeschlagen (Zugangsdaten prüfen; iCloud braucht ein App-spezifisches Passwort): %w", err)
|
||||||
|
}
|
||||||
|
homeSet, err := c.FindCalendarHomeSet(ctx, principal)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("CalDAV: Kalender-Verzeichnis nicht gefunden: %w", err)
|
||||||
|
}
|
||||||
|
cals, err := c.FindCalendars(ctx, homeSet)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("CalDAV: Kalender konnten nicht geladen werden: %w", err)
|
||||||
|
}
|
||||||
|
for _, cal := range cals {
|
||||||
|
for _, comp := range cal.SupportedComponentSet {
|
||||||
|
if strings.EqualFold(comp, "VEVENT") {
|
||||||
|
return cal.Path, cal.Name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("CalDAV: kein Kalender mit Terminen gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CalDAVProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) {
|
||||||
|
c, err := newCalDAVClient(p.cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Der Zeitfilter auf dem Server expandiert auch Serientermine (RFC 4791).
|
||||||
|
query := &caldav.CalendarQuery{
|
||||||
|
CompRequest: caldav.CalendarCompRequest{
|
||||||
|
Name: "VCALENDAR",
|
||||||
|
Comps: []caldav.CalendarCompRequest{{Name: "VEVENT"}},
|
||||||
|
},
|
||||||
|
CompFilter: caldav.CompFilter{
|
||||||
|
Name: "VCALENDAR",
|
||||||
|
Comps: []caldav.CompFilter{{
|
||||||
|
Name: "VEVENT",
|
||||||
|
Start: from,
|
||||||
|
End: to,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
objects, err := c.QueryCalendar(ctx, p.cfg.CalendarPath, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CalDAV-Abfrage fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []Interval
|
||||||
|
for _, obj := range objects {
|
||||||
|
if obj.Data == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ev := range obj.Data.Events() {
|
||||||
|
if prop := ev.Props.Get(ical.PropStatus); prop != nil && strings.EqualFold(prop.Value, "CANCELLED") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Transparente Termine ("Verfügbar") blockieren keine Zeit.
|
||||||
|
if prop := ev.Props.Get("TRANSP"); prop != nil && strings.EqualFold(prop.Value, "TRANSPARENT") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start, err := ev.DateTimeStart(time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end, err := ev.DateTimeEnd(time.UTC)
|
||||||
|
if err != nil || !end.After(start) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Interval{Start: start, End: end})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
151
backend/internal/calendar/google.go
Normal file
151
backend/internal/calendar/google.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GoogleConnConfig ist der verschlüsselt gespeicherte Zustand einer
|
||||||
|
// Google-Kalenderverbindung.
|
||||||
|
type GoogleConnConfig struct {
|
||||||
|
Token *oauth2.Token `json:"token"`
|
||||||
|
CalendarID string `json:"calendar_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleOAuthConfig baut die OAuth2-Konfiguration. Die FreeBusy-API von Google
|
||||||
|
// liefert ausschließlich busy-Zeiträume – gar keine Termindetails.
|
||||||
|
func GoogleOAuthConfig(clientID, clientSecret, redirectURL string) *oauth2.Config {
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
RedirectURL: redirectURL,
|
||||||
|
Scopes: []string{"https://www.googleapis.com/auth/calendar.readonly"},
|
||||||
|
Endpoint: google.Endpoint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GoogleProvider struct {
|
||||||
|
conf *oauth2.Config
|
||||||
|
cfg GoogleConnConfig
|
||||||
|
onTokenSaved func(*oauth2.Token) // wird aufgerufen, wenn das Token erneuert wurde
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGoogleProvider(conf *oauth2.Config, cfg GoogleConnConfig, onTokenSaved func(*oauth2.Token)) *GoogleProvider {
|
||||||
|
return &GoogleProvider{conf: conf, cfg: cfg, onTokenSaved: onTokenSaved}
|
||||||
|
}
|
||||||
|
|
||||||
|
type googleFreeBusyRequest struct {
|
||||||
|
TimeMin string `json:"timeMin"`
|
||||||
|
TimeMax string `json:"timeMax"`
|
||||||
|
Items []googleFreeBusyRequestItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type googleFreeBusyRequestItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type googleFreeBusyResponse struct {
|
||||||
|
Calendars map[string]struct {
|
||||||
|
Busy []struct {
|
||||||
|
Start time.Time `json:"start"`
|
||||||
|
End time.Time `json:"end"`
|
||||||
|
} `json:"busy"`
|
||||||
|
Errors []struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
} `json:"errors"`
|
||||||
|
} `json:"calendars"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GoogleProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) {
|
||||||
|
ts := p.conf.TokenSource(ctx, p.cfg.Token)
|
||||||
|
tok, err := ts.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Google-Zugriff ungültig (neu verbinden): %w", err)
|
||||||
|
}
|
||||||
|
if tok.AccessToken != p.cfg.Token.AccessToken {
|
||||||
|
saved := *tok
|
||||||
|
p.cfg.Token = &saved
|
||||||
|
if p.onTokenSaved != nil {
|
||||||
|
p.onTokenSaved(&saved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(googleFreeBusyRequest{
|
||||||
|
TimeMin: from.UTC().Format(time.RFC3339),
|
||||||
|
TimeMax: to.UTC().Format(time.RFC3339),
|
||||||
|
Items: []googleFreeBusyRequestItem{{ID: p.cfg.CalendarID}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := oauth2.NewClient(ctx, ts)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
"https://www.googleapis.com/calendar/v3/freeBusy", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Google FreeBusy-Anfrage fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Google FreeBusy-Fehler (HTTP %d): %.200s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
var out googleFreeBusyResponse
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("Google FreeBusy-Antwort unlesbar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cal, ok := out.Calendars[p.cfg.CalendarID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("Google: Kalender %s nicht in Antwort", p.cfg.CalendarID)
|
||||||
|
}
|
||||||
|
if len(cal.Errors) > 0 {
|
||||||
|
return nil, fmt.Errorf("Google: %s", cal.Errors[0].Reason)
|
||||||
|
}
|
||||||
|
intervals := make([]Interval, 0, len(cal.Busy))
|
||||||
|
for _, b := range cal.Busy {
|
||||||
|
intervals = append(intervals, Interval{Start: b.Start, End: b.End})
|
||||||
|
}
|
||||||
|
return intervals, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GooglePrimaryCalendar liefert ID und Namen des Primärkalenders – genutzt
|
||||||
|
// direkt nach dem OAuth-Flow.
|
||||||
|
func GooglePrimaryCalendar(ctx context.Context, client *http.Client) (id, summary string, err error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||||
|
"https://www.googleapis.com/calendar/v3/calendars/primary", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("Google: Primärkalender nicht lesbar (HTTP %d): %.200s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var cal struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &cal); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return cal.ID, cal.Summary, nil
|
||||||
|
}
|
||||||
270
backend/internal/calendar/ics.go
Normal file
270
backend/internal/calendar/ics.go
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ICSConnConfig: Abo eines beliebigen Kalenders per ICS-/webcal-Link
|
||||||
|
// (z. B. die "private Adresse im iCal-Format" von Google oder Outlook-Feeds).
|
||||||
|
type ICSConnConfig struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ICSProvider struct {
|
||||||
|
URL string
|
||||||
|
// DefaultTZ wird für "floating" Zeiten (ohne TZID/UTC) genutzt,
|
||||||
|
// typischerweise die Zeitzone des Kalenderbesitzers.
|
||||||
|
DefaultTZ string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewICSProvider(cfg ICSConnConfig, defaultTZ string) *ICSProvider {
|
||||||
|
return &ICSProvider{URL: cfg.URL, DefaultTZ: defaultTZ}
|
||||||
|
}
|
||||||
|
|
||||||
|
type icsEvent struct {
|
||||||
|
start time.Time
|
||||||
|
hasStart bool
|
||||||
|
allDay bool
|
||||||
|
end time.Time
|
||||||
|
cancelled bool
|
||||||
|
transparent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ICSProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "WannPassts/1.0 (+calendar-sync)")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ICS-Link nicht abrufbar: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("ICS-Link antwortet mit HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ICS-Antwort nicht lesbar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defLoc := time.UTC
|
||||||
|
if p.DefaultTZ != "" {
|
||||||
|
if loc, err := time.LoadLocation(p.DefaultTZ); err == nil {
|
||||||
|
defLoc = loc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events := parseICS(string(raw), defLoc)
|
||||||
|
var out []Interval
|
||||||
|
for _, ev := range events {
|
||||||
|
if ev.cancelled || ev.transparent || !ev.hasStart {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := ev.end
|
||||||
|
if end.IsZero() || !end.After(ev.start) {
|
||||||
|
if ev.allDay {
|
||||||
|
end = ev.start.Add(24 * time.Hour)
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, Interval{Start: ev.start, End: end})
|
||||||
|
}
|
||||||
|
return clampIntervals(out, from, to), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseICS liest VEVENTs aus ICS-Daten. Zeilen werden entfaltet (RFC 5545),
|
||||||
|
// Parameter wie TZID/VALUE=DATE ausgewertet. Serientermine werden als ihre
|
||||||
|
// Basis-Instanz übernommen – ICS-Abos sind damit der "best effort"-Weg,
|
||||||
|
// CalDAV und Google expandieren Serien serverseitig korrekt.
|
||||||
|
func parseICS(input string, defLoc *time.Location) []icsEvent {
|
||||||
|
input = strings.ReplaceAll(input, "\r\n", "\n")
|
||||||
|
rawLines := strings.Split(input, "\n")
|
||||||
|
lines := make([]string, 0, len(rawLines))
|
||||||
|
for _, l := range rawLines {
|
||||||
|
if strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t") {
|
||||||
|
if len(lines) > 0 {
|
||||||
|
lines[len(lines)-1] += l[1:]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, strings.TrimSuffix(l, "\r"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var events []icsEvent
|
||||||
|
var cur *icsEvent
|
||||||
|
for _, line := range lines {
|
||||||
|
upper := strings.ToUpper(strings.TrimSpace(line))
|
||||||
|
switch {
|
||||||
|
case upper == "BEGIN:VEVENT":
|
||||||
|
cur = &icsEvent{}
|
||||||
|
continue
|
||||||
|
case upper == "END:VEVENT":
|
||||||
|
if cur != nil {
|
||||||
|
events = append(events, *cur)
|
||||||
|
cur = nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, params, value := splitICSLine(line)
|
||||||
|
switch name {
|
||||||
|
case "DTSTART":
|
||||||
|
if t, allDay, ok := parseICSDateTime(value, params, defLoc); ok {
|
||||||
|
cur.start, cur.hasStart, cur.allDay = t, true, allDay
|
||||||
|
}
|
||||||
|
case "DTEND":
|
||||||
|
if t, _, ok := parseICSDateTime(value, params, defLoc); ok {
|
||||||
|
cur.end = t
|
||||||
|
}
|
||||||
|
case "DURATION":
|
||||||
|
if cur.hasStart {
|
||||||
|
if d, ok := parseISODuration(value); ok {
|
||||||
|
cur.end = cur.start.Add(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "STATUS":
|
||||||
|
if strings.EqualFold(value, "CANCELLED") {
|
||||||
|
cur.cancelled = true
|
||||||
|
}
|
||||||
|
case "TRANSP":
|
||||||
|
if strings.EqualFold(value, "TRANSPARENT") {
|
||||||
|
cur.transparent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitICSLine zerlegt "NAME;PARAM=WERT;...:value" am ersten Doppelpunkt.
|
||||||
|
func splitICSLine(line string) (name string, params map[string]string, value string) {
|
||||||
|
params = map[string]string{}
|
||||||
|
i := strings.IndexByte(line, ':')
|
||||||
|
if i == -1 {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(line)), params, ""
|
||||||
|
}
|
||||||
|
head, value := line[:i], strings.TrimSpace(line[i+1:])
|
||||||
|
parts := strings.Split(head, ";")
|
||||||
|
name = strings.ToUpper(strings.TrimSpace(parts[0]))
|
||||||
|
for _, p := range parts[1:] {
|
||||||
|
if eq := strings.IndexByte(p, '='); eq > 0 {
|
||||||
|
key := strings.ToUpper(strings.TrimSpace(p[:eq]))
|
||||||
|
params[key] = strings.Trim(strings.TrimSpace(p[eq+1:]), `"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name, params, value
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseICSDateTime(value string, params map[string]string, defLoc *time.Location) (t time.Time, allDay, ok bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, false, false
|
||||||
|
}
|
||||||
|
if params["VALUE"] == "DATE" || (len(value) == 8 && allDigits(value)) {
|
||||||
|
y, err1 := strconv.Atoi(value[0:4])
|
||||||
|
m, err2 := strconv.Atoi(value[4:6])
|
||||||
|
d, err3 := strconv.Atoi(value[6:8])
|
||||||
|
if err1 != nil || err2 != nil || err3 != nil {
|
||||||
|
return time.Time{}, false, false
|
||||||
|
}
|
||||||
|
return time.Date(y, time.Month(m), d, 0, 0, 0, 0, defLoc), true, true
|
||||||
|
}
|
||||||
|
layouts := []struct {
|
||||||
|
layout string
|
||||||
|
utc bool
|
||||||
|
}{
|
||||||
|
{"20060102T150405Z", true},
|
||||||
|
{"20060102T150405", false},
|
||||||
|
{"2006-01-02T15:04:05Z07:00", true},
|
||||||
|
{"2006-01-02T15:04:05", false},
|
||||||
|
}
|
||||||
|
for _, l := range layouts {
|
||||||
|
if parsed, err := time.Parse(l.layout, value); err == nil {
|
||||||
|
if l.utc {
|
||||||
|
return parsed.UTC(), false, true
|
||||||
|
}
|
||||||
|
// Wanduhrzeit in der Zielzone interpretieren (nicht instant-konvertieren)
|
||||||
|
loc := defLoc
|
||||||
|
if tzid := params["TZID"]; tzid != "" {
|
||||||
|
if loaded, err := time.LoadLocation(tzid); err == nil {
|
||||||
|
loc = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y, mo, d := parsed.Date()
|
||||||
|
hh, mm, ss := parsed.Clock()
|
||||||
|
return time.Date(y, mo, d, hh, mm, ss, 0, loc), false, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func allDigits(s string) bool {
|
||||||
|
for _, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseISODuration versteht die üblichen ICS-Dauerformen: PT15M, PT1H30M,
|
||||||
|
// P1D, P2W, PT45S …
|
||||||
|
func parseISODuration(s string) (time.Duration, bool) {
|
||||||
|
s = strings.TrimSpace(strings.ToUpper(s))
|
||||||
|
if len(s) < 2 || s[0] != 'P' || strings.Contains(s, "-") {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
body := s[1:]
|
||||||
|
if strings.HasSuffix(body, "W") {
|
||||||
|
v, err := strconv.ParseFloat(strings.TrimSuffix(body, "W"), 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return time.Duration(v * float64(7*24*time.Hour)), true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(body, "T") {
|
||||||
|
body = body[1:]
|
||||||
|
}
|
||||||
|
units := map[byte]time.Duration{
|
||||||
|
'D': 24 * time.Hour,
|
||||||
|
'H': time.Hour,
|
||||||
|
'M': time.Minute,
|
||||||
|
'S': time.Second,
|
||||||
|
}
|
||||||
|
var total time.Duration
|
||||||
|
num := ""
|
||||||
|
flush := func(unit time.Duration) bool {
|
||||||
|
if num == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(num, 64)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
total += time.Duration(v * float64(unit))
|
||||||
|
num = ""
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i := 0; i < len(body); i++ {
|
||||||
|
c := body[i]
|
||||||
|
if (c >= '0' && c <= '9') || c == '.' {
|
||||||
|
num += string(c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unit, known := units[c]
|
||||||
|
if !known || !flush(unit) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, num == ""
|
||||||
|
}
|
||||||
145
backend/internal/calendar/ics_test.go
Normal file
145
backend/internal/calendar/ics_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleICS = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Test//EN
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Berlin
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19701025T030000
|
||||||
|
TZOFFSETFROM:+0200
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:1
|
||||||
|
DTSTART;TZID=Europe/Berlin:20260825T090000
|
||||||
|
DTEND;TZID=Europe/Berlin:20260825T100000
|
||||||
|
SUMMARY:Zahnarzt
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:2
|
||||||
|
DTSTART:20260826T120000Z
|
||||||
|
DURATION:PT45M
|
||||||
|
SUMMARY:Call
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:3
|
||||||
|
DTSTART;VALUE=DATE:20260827
|
||||||
|
SUMMARY:Ganztägig
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:4
|
||||||
|
DTSTART:20260828T080000Z
|
||||||
|
DTEND:20260828T090000Z
|
||||||
|
STATUS:CANCELLED
|
||||||
|
SUMMARY:Abgesagt
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:5
|
||||||
|
DTSTART:20260828T100000Z
|
||||||
|
DTEND:20260828T110000Z
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
SUMMARY:Verfügbar
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:6
|
||||||
|
DTSTART:20260829T080000Z
|
||||||
|
DTEND:202609
|
||||||
|
01T080000Z
|
||||||
|
SUMMARY:Gefaltet
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseICS(t *testing.T) {
|
||||||
|
berlin, err := time.LoadLocation("Europe/Berlin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
events := parseICS(sampleICS, berlin)
|
||||||
|
if len(events) != 6 {
|
||||||
|
t.Fatalf("6 Events erwartet, got %d", len(events))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TZID-Event
|
||||||
|
if !events[0].start.Equal(time.Date(2026, 8, 25, 9, 0, 0, 0, berlin)) {
|
||||||
|
t.Errorf("TZID-Start falsch: %v", events[0].start)
|
||||||
|
}
|
||||||
|
if !events[0].end.Equal(time.Date(2026, 8, 25, 10, 0, 0, 0, berlin)) {
|
||||||
|
t.Errorf("TZID-Ende falsch: %v", events[0].end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC + DURATION
|
||||||
|
if !events[1].end.Equal(events[1].start.Add(45*time.Minute)) {
|
||||||
|
t.Errorf("DURATION nicht angewandt: %v", events[1].end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ganztägig
|
||||||
|
if !events[2].allDay {
|
||||||
|
t.Error("VALUE=DATE nicht erkannt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelled / Transparent markiert
|
||||||
|
if !events[3].cancelled {
|
||||||
|
t.Error("CANCELLED nicht erkannt")
|
||||||
|
}
|
||||||
|
if !events[4].transparent {
|
||||||
|
t.Error("TRANSPARENT nicht erkannt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gefaltete Zeile (DTEND über zwei Zeilen)
|
||||||
|
if !events[5].end.Equal(time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("Folding nicht aufgelöst, Ende: %v", events[5].end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseISODuration(t *testing.T) {
|
||||||
|
cases := map[string]time.Duration{
|
||||||
|
"PT15M": 15 * time.Minute,
|
||||||
|
"PT1H30M": 90 * time.Minute,
|
||||||
|
"P1D": 24 * time.Hour,
|
||||||
|
"P2W": 14 * 24 * time.Hour,
|
||||||
|
"PT45S": 45 * time.Second,
|
||||||
|
"": 0,
|
||||||
|
"X": 0,
|
||||||
|
"P-1D": 0,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
got, ok := parseISODuration(in)
|
||||||
|
if in == "" || in == "X" || in == "P-1D" {
|
||||||
|
if ok {
|
||||||
|
t.Errorf("%q sollte ungültig sein", in)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ok || got != want {
|
||||||
|
t.Errorf("parseISODuration(%q) = %v (ok=%v), want %v", in, got, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClampIntervals(t *testing.T) {
|
||||||
|
from := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC)
|
||||||
|
to := from.Add(48 * time.Hour)
|
||||||
|
in := []Interval{
|
||||||
|
{Start: from.Add(-2 * time.Hour), End: from.Add(time.Hour)},
|
||||||
|
{Start: from.Add(3 * time.Hour), End: from.Add(2 * time.Hour)}, // ungültig
|
||||||
|
{Start: from.Add(10 * time.Hour), End: to.Add(time.Hour)},
|
||||||
|
}
|
||||||
|
out := clampIntervals(in, from, to)
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("2 Intervalle erwartet, got %d", len(out))
|
||||||
|
}
|
||||||
|
if !out[0].Start.Equal(from) || !out[0].End.Equal(from.Add(time.Hour)) {
|
||||||
|
t.Errorf("Intervall 1 nicht geklemmt: %v–%v", out[0].Start, out[0].End)
|
||||||
|
}
|
||||||
|
if !out[1].End.Equal(to) {
|
||||||
|
t.Errorf("Intervall 2 nicht geklemmt: %v", out[1].End)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/internal/calendar/provider.go
Normal file
41
backend/internal/calendar/provider.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Interval ist ein "beschäftigt"-Zeitraum. Enthält bewusst KEINE Termindetails,
|
||||||
|
// nur Start und Ende – mehr verlässt den Kalender des Nutzers nie.
|
||||||
|
type Interval struct {
|
||||||
|
Start time.Time
|
||||||
|
End time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Provider interface {
|
||||||
|
// FetchBusy liefert alle busy-Zeiträume im Fenster [from, to).
|
||||||
|
FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampIntervals schneidet Intervalle aufs Fenster zurecht, verwirft ungültige
|
||||||
|
// und sortiert das Ergebnis.
|
||||||
|
func clampIntervals(in []Interval, from, to time.Time) []Interval {
|
||||||
|
out := make([]Interval, 0, len(in))
|
||||||
|
for _, iv := range in {
|
||||||
|
if !iv.End.After(iv.Start) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if iv.Start.Before(from) {
|
||||||
|
iv.Start = from
|
||||||
|
}
|
||||||
|
if iv.End.After(to) {
|
||||||
|
iv.End = to
|
||||||
|
}
|
||||||
|
if iv.End.After(iv.Start) {
|
||||||
|
out = append(out, iv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
139
backend/internal/calendar/sync.go
Normal file
139
backend/internal/calendar/sync.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
|
"wannpassts/internal/config"
|
||||||
|
"wannpassts/internal/crypto"
|
||||||
|
"wannpassts/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const syncStaleAfter = 5 * time.Minute
|
||||||
|
|
||||||
|
type Syncer struct {
|
||||||
|
DB *db.DB
|
||||||
|
Cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsSync meldet, ob eine der Verbindungen älter als syncStaleAfter ist.
|
||||||
|
func NeedsSync(conns []*db.Connection, now time.Time) bool {
|
||||||
|
for _, c := range conns {
|
||||||
|
if !c.LastSyncedTs.Valid || now.Unix()-c.LastSyncedTs.Int64 > int64(syncStaleAfter.Seconds()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncUser aktualisiert die busy-Zeiträume aller Verbindungen eines Nutzers
|
||||||
|
// für das Buchungsfenster [jetzt, jetzt+horizon]. Fehler einzelner Provider
|
||||||
|
// werden pro Verbindung gespeichert und werfen den Gesamtsync nicht ab.
|
||||||
|
func (s *Syncer) SyncUser(ctx context.Context, user *db.User) {
|
||||||
|
conns, err := s.DB.ConnectionsForUser(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sync: Verbindungen von User %d nicht lesbar: %v", user.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(conns) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
from := time.Now().Add(-time.Hour)
|
||||||
|
to := time.Now().Add(time.Duration(user.HorizonDays) * 24 * time.Hour)
|
||||||
|
|
||||||
|
for _, conn := range conns {
|
||||||
|
s.syncConnection(ctx, conn, user, from, to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) syncConnection(ctx context.Context, conn *db.Connection, user *db.User, from, to time.Time) {
|
||||||
|
prov, err := s.provider(conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.DB.SetConnectionState(conn.ID, false, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
busy, err := prov.FetchBusy(cctx, from, to)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.DB.SetConnectionState(conn.ID, false, cleanErr(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slots := make([]db.BusySlot, 0, len(busy))
|
||||||
|
for _, iv := range busy {
|
||||||
|
slots = append(slots, db.BusySlot{StartTs: iv.Start.Unix(), EndTs: iv.End.Unix()})
|
||||||
|
}
|
||||||
|
if err := s.DB.ReplaceBusyForConnection(conn.ID, user.ID, slots); err != nil {
|
||||||
|
_ = s.DB.SetConnectionState(conn.ID, false, "Speichern fehlgeschlagen: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.DB.SetConnectionState(conn.ID, true, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncInBackground startet den Sync ohne den Aufrufer zu blocken.
|
||||||
|
func (s *Syncer) SyncInBackground(user *db.User) {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
s.SyncUser(ctx, user)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) provider(conn *db.Connection) (Provider, error) {
|
||||||
|
plain, err := crypto.Decrypt(s.Cfg.EncryptionKey, conn.ConfigEnc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gespeicherte Verbindung nicht entschlüsselbar (ENCRYPTION_KEY geändert?): %w", err)
|
||||||
|
}
|
||||||
|
switch conn.Provider {
|
||||||
|
case "google":
|
||||||
|
var cfg GoogleConnConfig
|
||||||
|
if err := json.Unmarshal(plain, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("Google-Konfiguration unlesbar: %w", err)
|
||||||
|
}
|
||||||
|
oconf := GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
|
||||||
|
s.Cfg.AppURL+"/api/calendars/google/callback")
|
||||||
|
calendarID := cfg.CalendarID
|
||||||
|
return NewGoogleProvider(oconf, cfg, func(t *oauth2.Token) {
|
||||||
|
s.saveGoogleToken(conn.ID, calendarID, t)
|
||||||
|
}), nil
|
||||||
|
case "caldav":
|
||||||
|
var cfg CalDAVConnConfig
|
||||||
|
if err := json.Unmarshal(plain, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("CalDAV-Konfiguration unlesbar: %w", err)
|
||||||
|
}
|
||||||
|
return NewCalDAVProvider(cfg), nil
|
||||||
|
case "ics":
|
||||||
|
var cfg ICSConnConfig
|
||||||
|
if err := json.Unmarshal(plain, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("ICS-Konfiguration unlesbar: %w", err)
|
||||||
|
}
|
||||||
|
return NewICSProvider(cfg, ""), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unbekannter Provider %q", conn.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) saveGoogleToken(connID int64, calendarID string, t *oauth2.Token) {
|
||||||
|
data, err := json.Marshal(GoogleConnConfig{Token: t, CalendarID: calendarID})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enc, err := crypto.Encrypt(s.Cfg.EncryptionKey, data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.DB.UpdateConnectionConfig(connID, enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanErr(msg string) string {
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
if len(msg) > 300 {
|
||||||
|
msg = msg[:300] + "…"
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
64
backend/internal/config/config.go
Normal file
64
backend/internal/config/config.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
DBPath string
|
||||||
|
AppURL string
|
||||||
|
FrontendURL string
|
||||||
|
JWTSecret string
|
||||||
|
EncryptionKey string
|
||||||
|
GoogleClientID string
|
||||||
|
GoogleClientSecret string
|
||||||
|
StaticDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load liest die Konfiguration aus Umgebungsvariablen (.env wird unterstützt).
|
||||||
|
// Fehlende Secrets werden nur für die Entwicklung zufällig erzeugt.
|
||||||
|
func Load() Config {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
Port: env("PORT", "8080"),
|
||||||
|
DBPath: env("DB_PATH", "wannpassts.db"),
|
||||||
|
AppURL: env("APP_URL", "http://localhost:8080"),
|
||||||
|
FrontendURL: env("FRONTEND_URL", "http://localhost:5173"),
|
||||||
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
|
EncryptionKey: os.Getenv("ENCRYPTION_KEY"),
|
||||||
|
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
||||||
|
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
|
||||||
|
StaticDir: env("STATIC_DIR", ""),
|
||||||
|
}
|
||||||
|
if cfg.JWTSecret == "" {
|
||||||
|
cfg.JWTSecret = randomHex(32)
|
||||||
|
log.Println("WARNUNG: JWT_SECRET nicht gesetzt – temporäres Secret erzeugt (Sessions verfallen bei Neustart).")
|
||||||
|
}
|
||||||
|
if cfg.EncryptionKey == "" {
|
||||||
|
cfg.EncryptionKey = randomHex(32)
|
||||||
|
log.Println("WARNUNG: ENCRYPTION_KEY nicht gesetzt – temporärer Schlüssel erzeugt (Kalender-Tokens verfallen bei Neustart).")
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
log.Fatalf("Zufallszahl nicht verfügbar: %v", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
52
backend/internal/crypto/secret.go
Normal file
52
backend/internal/crypto/secret.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encrypt verschlüsselt plaintext mit AES-256-GCM (Key = SHA256(secret))
|
||||||
|
// und liefert base64(nonce + ciphertext).
|
||||||
|
func Encrypt(secret string, plaintext []byte) (string, error) {
|
||||||
|
k := sha256.Sum256([]byte(secret))
|
||||||
|
block, err := aes.NewCipher(k[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sealed := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||||
|
return base64.StdEncoding.EncodeToString(sealed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt macht Encrypt rückgängig.
|
||||||
|
func Decrypt(secret, s string) ([]byte, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
k := sha256.Sum256([]byte(secret))
|
||||||
|
block, err := aes.NewCipher(k[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(data) < gcm.NonceSize() {
|
||||||
|
return nil, errors.New("ciphertext zu kurz")
|
||||||
|
}
|
||||||
|
return gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], nil)
|
||||||
|
}
|
||||||
354
backend/internal/db/db.go
Normal file
354
backend/internal/db/db.go
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("nicht gefunden")
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
*sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
PasswordHash string
|
||||||
|
Slug string
|
||||||
|
Timezone string
|
||||||
|
SlotMinutes int
|
||||||
|
DayStartMin int
|
||||||
|
DayEndMin int
|
||||||
|
HorizonDays int
|
||||||
|
Durations string
|
||||||
|
Weekdays string
|
||||||
|
CreatedTs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Connection struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
Provider string // google | caldav | ics
|
||||||
|
DisplayName string
|
||||||
|
ConfigEnc string
|
||||||
|
LastSyncedTs sql.NullInt64
|
||||||
|
LastError sql.NullString
|
||||||
|
CreatedTs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type BusySlot struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
ConnectionID sql.NullInt64
|
||||||
|
StartTs int64
|
||||||
|
EndTs int64
|
||||||
|
BookingID sql.NullInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Booking struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
StartTs int64
|
||||||
|
EndTs int64
|
||||||
|
RequesterName string
|
||||||
|
RequesterEmail string
|
||||||
|
Message string
|
||||||
|
Status string // pending | accepted | declined
|
||||||
|
CreatedTs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
d, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// SQLite verträgt parallele Writer nicht gut – ein Writer reicht hier.
|
||||||
|
d.SetMaxOpenConns(1)
|
||||||
|
if err := d.Ping(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dbase := &DB{d}
|
||||||
|
if err := dbase.migrate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dbase, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) migrate() error {
|
||||||
|
stmts := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
timezone TEXT NOT NULL DEFAULT 'Europe/Berlin',
|
||||||
|
slot_minutes INTEGER NOT NULL DEFAULT 30,
|
||||||
|
day_start_min INTEGER NOT NULL DEFAULT 540,
|
||||||
|
day_end_min INTEGER NOT NULL DEFAULT 1080,
|
||||||
|
horizon_days INTEGER NOT NULL DEFAULT 21,
|
||||||
|
durations TEXT NOT NULL DEFAULT '15,30,60,120',
|
||||||
|
weekdays TEXT NOT NULL DEFAULT '1,2,3,4,5',
|
||||||
|
created_ts INTEGER NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS calendar_connections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
config_enc TEXT NOT NULL,
|
||||||
|
last_synced_ts INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_ts INTEGER NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS busy_slots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
connection_id INTEGER REFERENCES calendar_connections(id) ON DELETE CASCADE,
|
||||||
|
booking_id INTEGER,
|
||||||
|
start_ts INTEGER NOT NULL,
|
||||||
|
end_ts INTEGER NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_busy_user_time ON busy_slots(user_id, start_ts)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_busy_booking ON busy_slots(booking_id)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS booking_requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
start_ts INTEGER NOT NULL,
|
||||||
|
end_ts INTEGER NOT NULL,
|
||||||
|
requester_name TEXT NOT NULL,
|
||||||
|
requester_email TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_ts INTEGER NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_bookings_user ON booking_requests(user_id, created_ts)`,
|
||||||
|
}
|
||||||
|
for _, s := range stmts {
|
||||||
|
if _, err := d.Exec(s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Users ---
|
||||||
|
|
||||||
|
func (d *DB) CreateUser(u *User) error {
|
||||||
|
return d.QueryRow(
|
||||||
|
`INSERT INTO users (email, name, password_hash, slug, timezone, created_ts)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||||
|
u.Email, u.Name, u.PasswordHash, u.Slug, u.Timezone, time.Now().Unix(),
|
||||||
|
).Scan(&u.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCols = `id, email, name, password_hash, slug, timezone, slot_minutes, day_start_min, day_end_min, horizon_days, durations, weekdays, created_ts`
|
||||||
|
|
||||||
|
func scanUser(row interface{ Scan(...any) error }) (*User, error) {
|
||||||
|
u := &User{}
|
||||||
|
err := row.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.Slug, &u.Timezone,
|
||||||
|
&u.SlotMinutes, &u.DayStartMin, &u.DayEndMin, &u.HorizonDays, &u.Durations, &u.Weekdays, &u.CreatedTs)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UserByEmail(email string) (*User, error) {
|
||||||
|
return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE email = ?`, email))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UserByID(id int64) (*User, error) {
|
||||||
|
return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE id = ?`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UserBySlug(slug string) (*User, error) {
|
||||||
|
return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE slug = ?`, slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SlugExists(slug string) (bool, error) {
|
||||||
|
var one int
|
||||||
|
err := d.QueryRow(`SELECT 1 FROM users WHERE slug = ?`, slug).Scan(&one)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateUserSettings(u *User) error {
|
||||||
|
_, err := d.Exec(`UPDATE users SET name=?, timezone=?, slot_minutes=?, day_start_min=?, day_end_min=?, horizon_days=?, durations=?, weekdays=? WHERE id=?`,
|
||||||
|
u.Name, u.Timezone, u.SlotMinutes, u.DayStartMin, u.DayEndMin, u.HorizonDays, u.Durations, u.Weekdays, u.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateUserSlug(id int64, slug string) error {
|
||||||
|
_, err := d.Exec(`UPDATE users SET slug=? WHERE id=?`, slug, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Connections ---
|
||||||
|
|
||||||
|
func (d *DB) CreateConnection(userID int64, provider, displayName, configEnc string) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := d.QueryRow(
|
||||||
|
`INSERT INTO calendar_connections (user_id, provider, display_name, config_enc, created_ts)
|
||||||
|
VALUES (?, ?, ?, ?, ?) RETURNING id`,
|
||||||
|
userID, provider, displayName, configEnc, time.Now().Unix(),
|
||||||
|
).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const connCols = `id, user_id, provider, display_name, config_enc, last_synced_ts, last_error, created_ts`
|
||||||
|
|
||||||
|
func (d *DB) ConnectionsForUser(userID int64) ([]*Connection, error) {
|
||||||
|
rows, err := d.Query(`SELECT `+connCols+` FROM calendar_connections WHERE user_id = ? ORDER BY created_ts`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*Connection
|
||||||
|
for rows.Next() {
|
||||||
|
c := &Connection{}
|
||||||
|
if err := rows.Scan(&c.ID, &c.UserID, &c.Provider, &c.DisplayName, &c.ConfigEnc, &c.LastSyncedTs, &c.LastError, &c.CreatedTs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ConnectionByID(id, userID int64) (*Connection, error) {
|
||||||
|
c := &Connection{}
|
||||||
|
err := d.QueryRow(`SELECT `+connCols+` FROM calendar_connections WHERE id = ? AND user_id = ?`, id, userID).
|
||||||
|
Scan(&c.ID, &c.UserID, &c.Provider, &c.DisplayName, &c.ConfigEnc, &c.LastSyncedTs, &c.LastError, &c.CreatedTs)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteConnection(id, userID int64) error {
|
||||||
|
_, err := d.Exec(`DELETE FROM calendar_connections WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SetConnectionState(id int64, synced bool, errMsg string) error {
|
||||||
|
var syncedTs any
|
||||||
|
if synced {
|
||||||
|
syncedTs = time.Now().Unix()
|
||||||
|
}
|
||||||
|
_, err := d.Exec(`UPDATE calendar_connections SET last_synced_ts = ?, last_error = ? WHERE id = ?`, syncedTs, errMsg, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateConnectionConfig(id int64, configEnc string) error {
|
||||||
|
_, err := d.Exec(`UPDATE calendar_connections SET config_enc = ? WHERE id = ?`, configEnc, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Busy slots ---
|
||||||
|
|
||||||
|
func (d *DB) ReplaceBusyForConnection(connID, userID int64, slots []BusySlot) error {
|
||||||
|
tx, err := d.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(`DELETE FROM busy_slots WHERE connection_id = ?`, connID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, s := range slots {
|
||||||
|
if _, err := tx.Exec(`INSERT INTO busy_slots (user_id, connection_id, start_ts, end_ts) VALUES (?, ?, ?, ?)`,
|
||||||
|
userID, connID, s.StartTs, s.EndTs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) BusyForUser(userID, fromTs, toTs int64) ([]BusySlot, error) {
|
||||||
|
rows, err := d.Query(`SELECT id, user_id, connection_id, start_ts, end_ts, booking_id FROM busy_slots
|
||||||
|
WHERE user_id = ? AND end_ts > ? AND start_ts < ? ORDER BY start_ts`, userID, fromTs, toTs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []BusySlot
|
||||||
|
for rows.Next() {
|
||||||
|
var s BusySlot
|
||||||
|
if err := rows.Scan(&s.ID, &s.UserID, &s.ConnectionID, &s.StartTs, &s.EndTs, &s.BookingID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bookings ---
|
||||||
|
|
||||||
|
func (d *DB) CreateBooking(b *Booking) error {
|
||||||
|
return d.QueryRow(
|
||||||
|
`INSERT INTO booking_requests (user_id, start_ts, end_ts, requester_name, requester_email, message, status, created_ts)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?) RETURNING id`,
|
||||||
|
b.UserID, b.StartTs, b.EndTs, b.RequesterName, b.RequesterEmail, b.Message, time.Now().Unix(),
|
||||||
|
).Scan(&b.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingCols = `id, user_id, start_ts, end_ts, requester_name, requester_email, message, status, created_ts`
|
||||||
|
|
||||||
|
func (d *DB) BookingsForUser(userID int64) ([]*Booking, error) {
|
||||||
|
rows, err := d.Query(`SELECT `+bookingCols+` FROM booking_requests WHERE user_id = ? ORDER BY (status='pending') DESC, start_ts ASC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*Booking
|
||||||
|
for rows.Next() {
|
||||||
|
b := &Booking{}
|
||||||
|
if err := rows.Scan(&b.ID, &b.UserID, &b.StartTs, &b.EndTs, &b.RequesterName, &b.RequesterEmail, &b.Message, &b.Status, &b.CreatedTs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) BookingByID(id, userID int64) (*Booking, error) {
|
||||||
|
b := &Booking{}
|
||||||
|
err := d.QueryRow(`SELECT `+bookingCols+` FROM booking_requests WHERE id = ? AND user_id = ?`, id, userID).
|
||||||
|
Scan(&b.ID, &b.UserID, &b.StartTs, &b.EndTs, &b.RequesterName, &b.RequesterEmail, &b.Message, &b.Status, &b.CreatedTs)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SetBookingStatus(id int64, status string) error {
|
||||||
|
_, err := d.Exec(`UPDATE booking_requests SET status = ? WHERE id = ?`, status, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) InsertBusyForBooking(b *Booking) error {
|
||||||
|
_, err := d.Exec(`INSERT INTO busy_slots (user_id, booking_id, start_ts, end_ts) VALUES (?, ?, ?, ?)`,
|
||||||
|
b.UserID, b.ID, b.StartTs, b.EndTs)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteBusyByBooking(bookingID int64) error {
|
||||||
|
_, err := d.Exec(`DELETE FROM busy_slots WHERE booking_id = ?`, bookingID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
289
backend/internal/httpapi/auth.go
Normal file
289
backend/internal/httpapi/auth.go
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"wannpassts/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokenTTL = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
func (s *Server) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
}
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
||||||
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
if !validEmail(in.Email) {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(in.Password) < 8 || len(in.Password) > 200 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Das Passwort muss 8–200 Zeichen lang sein")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Name == "" || len(in.Name) > 80 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 80 Zeichen) angeben")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tz := in.Timezone
|
||||||
|
if tz == "" {
|
||||||
|
tz = "Europe/Berlin"
|
||||||
|
}
|
||||||
|
if _, err := time.LoadLocation(tz); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.DB.UserByEmail(in.Email); err == nil {
|
||||||
|
jsonError(w, http.StatusConflict, "Diese E-Mail-Adresse ist bereits registriert")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 10)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug, err := s.uniqueSlug()
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := &db.User{Email: in.Email, Name: in.Name, PasswordHash: string(hash), Slug: slug, Timezone: tz}
|
||||||
|
if err := s.DB.CreateUser(u); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Konto konnte nicht erstellt werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := s.signJWT(u.ID, tokenTTL)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": userToJSON(u)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := s.DB.UserByEmail(strings.ToLower(strings.TrimSpace(in.Email)))
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Password)) != nil {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := s.signJWT(u.ID, tokenTTL)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": userToJSON(u)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(userFrom(r))})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := userFrom(r)
|
||||||
|
var in struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Timezone *string `json:"timezone"`
|
||||||
|
SlotMinutes *int `json:"slot_minutes"`
|
||||||
|
DayStartMin *int `json:"day_start_min"`
|
||||||
|
DayEndMin *int `json:"day_end_min"`
|
||||||
|
HorizonDays *int `json:"horizon_days"`
|
||||||
|
Durations []int `json:"durations"`
|
||||||
|
Weekdays []int `json:"weekdays"`
|
||||||
|
}
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Name != nil {
|
||||||
|
name := strings.TrimSpace(*in.Name)
|
||||||
|
if name == "" || len(name) > 80 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Name muss 1–80 Zeichen haben")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.Name = name
|
||||||
|
}
|
||||||
|
if in.Timezone != nil {
|
||||||
|
if _, err := time.LoadLocation(*in.Timezone); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.Timezone = *in.Timezone
|
||||||
|
}
|
||||||
|
if in.SlotMinutes != nil {
|
||||||
|
if *in.SlotMinutes < 5 || *in.SlotMinutes > 180 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Slot-Größe muss 5–180 Minuten sein")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.SlotMinutes = *in.SlotMinutes
|
||||||
|
}
|
||||||
|
if in.DayStartMin != nil {
|
||||||
|
if *in.DayStartMin < 0 || *in.DayStartMin > 1439 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Tagesbeginn ungültig")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.DayStartMin = *in.DayStartMin
|
||||||
|
}
|
||||||
|
if in.DayEndMin != nil {
|
||||||
|
if *in.DayEndMin < 1 || *in.DayEndMin > 1440 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Tagesende ungültig")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.DayEndMin = *in.DayEndMin
|
||||||
|
}
|
||||||
|
if u.DayStartMin >= u.DayEndMin {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Tagesbeginn muss vor dem Tagesende liegen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.HorizonDays != nil {
|
||||||
|
if *in.HorizonDays < 1 || *in.HorizonDays > 120 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Buchungshorizont muss 1–120 Tage sein")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.HorizonDays = *in.HorizonDays
|
||||||
|
}
|
||||||
|
if in.Durations != nil {
|
||||||
|
durations, err := cleanDurations(in.Durations)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.Durations = intsCSV(durations)
|
||||||
|
}
|
||||||
|
if in.Weekdays != nil {
|
||||||
|
weekdays, err := cleanWeekdays(in.Weekdays)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.Weekdays = intsCSV(weekdays)
|
||||||
|
}
|
||||||
|
if err := s.DB.UpdateUserSettings(u); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) RegenSlug(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := userFrom(r)
|
||||||
|
slug, err := s.uniqueSlug()
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.UpdateUserSlug(u.ID, slug); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.Slug = slug
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) uniqueSlug() (string, error) {
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
b := make([]byte, 9)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
slug := base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
exists, err := s.DB.SlugExists(slug)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return slug, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", http.ErrBodyNotAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
func validEmail(s string) bool {
|
||||||
|
if len(s) < 5 || len(s) > 254 || strings.Count(s, "@") != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
at := strings.IndexByte(s, '@')
|
||||||
|
local, domain := s[:at], s[at+1:]
|
||||||
|
if local == "" || !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !strings.ContainsAny(s, " \t")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanDurations(list []int) ([]int, error) {
|
||||||
|
if len(list) == 0 || len(list) > 8 {
|
||||||
|
return nil, errNew("1–8 Dauerangaben benötigt")
|
||||||
|
}
|
||||||
|
seen := map[int]bool{}
|
||||||
|
var out []int
|
||||||
|
for _, v := range list {
|
||||||
|
if v < 5 || v > 480 {
|
||||||
|
return nil, errNew("Dauern müssen 5–480 Minuten sein")
|
||||||
|
}
|
||||||
|
if !seen[v] {
|
||||||
|
seen[v] = true
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 1; i < len(out); i++ {
|
||||||
|
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||||
|
out[j], out[j-1] = out[j-1], out[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanWeekdays(list []int) ([]int, error) {
|
||||||
|
if len(list) == 0 || len(list) > 7 {
|
||||||
|
return nil, errNew("Mindestens ein Buchungstag nötig")
|
||||||
|
}
|
||||||
|
seen := map[int]bool{}
|
||||||
|
var out []int
|
||||||
|
for _, v := range list {
|
||||||
|
if v < 1 || v > 7 {
|
||||||
|
return nil, errNew("Wochentage müssen 1 (Mo) – 7 (So) sein")
|
||||||
|
}
|
||||||
|
if !seen[v] {
|
||||||
|
seen[v] = true
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 1; i < len(out); i++ {
|
||||||
|
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||||
|
out[j], out[j-1] = out[j-1], out[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func errNew(msg string) error { return &simpleError{msg} }
|
||||||
|
|
||||||
|
type simpleError struct{ msg string }
|
||||||
|
|
||||||
|
func (e *simpleError) Error() string { return e.msg }
|
||||||
93
backend/internal/httpapi/bookings.go
Normal file
93
backend/internal/httpapi/bookings.go
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) ListBookings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bookings, err := s.DB.BookingsForUser(userFrom(r).ID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Anfragen nicht lesbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]BookingJSON, 0, len(bookings))
|
||||||
|
for _, b := range bookings {
|
||||||
|
out = append(out, bookingToJSON(b))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) AcceptBooking(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r)
|
||||||
|
id, err := pathID(r)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Ungültige ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
booking, err := s.DB.BookingByID(id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusNotFound, "Anfrage nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if booking.Status != "pending" {
|
||||||
|
jsonError(w, http.StatusConflict, "Diese Anfrage wurde bereits bearbeitet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Kollision mit bestehenden busy-Zeiträumen prüfen (angommene Buchungen
|
||||||
|
// und Kalendertermine liegen beide in busy_slots).
|
||||||
|
busy, err := s.DB.BusyForUser(user.ID, booking.StartTs-1, booking.EndTs+1)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Belegung nicht prüfbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, b := range busy {
|
||||||
|
if b.BookingID.Valid && b.BookingID.Int64 == booking.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.StartTs < booking.EndTs && booking.StartTs < b.EndTs {
|
||||||
|
jsonError(w, http.StatusConflict, "Der Zeitraum kollidiert inzwischen mit einem anderen Termin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.DB.InsertBusyForBooking(booking); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Termin konnte nicht eingetragen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.SetBookingStatus(booking.ID, "accepted"); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
booking.Status = "accepted"
|
||||||
|
writeJSON(w, http.StatusOK, bookingToJSON(booking))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) DeclineBooking(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r)
|
||||||
|
id, err := pathID(r)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Ungültige ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
booking, err := s.DB.BookingByID(id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusNotFound, "Anfrage nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if booking.Status == "pending" {
|
||||||
|
if err := s.DB.SetBookingStatus(booking.ID, "declined"); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if booking.Status == "accepted" {
|
||||||
|
if err := s.DB.DeleteBusyByBooking(booking.ID); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Termin konnte nicht entfernt werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.SetBookingStatus(booking.ID, "declined"); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
booking.Status = "declined"
|
||||||
|
writeJSON(w, http.StatusOK, bookingToJSON(booking))
|
||||||
|
}
|
||||||
247
backend/internal/httpapi/calendars.go
Normal file
247
backend/internal/httpapi/calendars.go
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
|
"wannpassts/internal/calendar"
|
||||||
|
"wannpassts/internal/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) ListCalendars(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conns, err := s.DB.ConnectionsForUser(userFrom(r).ID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Verbindungen nicht lesbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]ConnectionJSON, 0, len(conns))
|
||||||
|
for _, c := range conns {
|
||||||
|
out = append(out, connToJSON(c))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) DeleteCalendar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := pathID(r)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Ungültige ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.DeleteConnection(id, userFrom(r).ID); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ConnectCalDAV(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r)
|
||||||
|
var in struct {
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
CalendarPath string `json:"calendar_path"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.ServerURL = strings.TrimSuffix(strings.TrimSpace(in.ServerURL), "/")
|
||||||
|
if !strings.HasPrefix(in.ServerURL, "http://") && !strings.HasPrefix(in.ServerURL, "https://") {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Server-URL muss mit http:// oder https:// beginnen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Username == "" || in.Password == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Benutzername und Passwort werden benötigt (iCloud: App-spezifisches Passwort)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := calendar.CalDAVConnConfig{
|
||||||
|
ServerURL: in.ServerURL,
|
||||||
|
Username: in.Username,
|
||||||
|
Password: in.Password,
|
||||||
|
CalendarPath: strings.TrimSpace(in.CalendarPath),
|
||||||
|
}
|
||||||
|
if cfg.CalendarPath == "" {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
path, name, err := calendar.DiscoverCalDAV(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.CalendarPath = path
|
||||||
|
if in.DisplayName == "" {
|
||||||
|
in.DisplayName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.DisplayName == "" {
|
||||||
|
if u, err := url.Parse(in.ServerURL); err == nil {
|
||||||
|
in.DisplayName = u.Host
|
||||||
|
} else {
|
||||||
|
in.DisplayName = "CalDAV-Kalender"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enc, err := s.encryptConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := s.DB.CreateConnection(user.ID, "caldav", in.DisplayName, enc)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Syncer.SyncInBackground(user)
|
||||||
|
writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "caldav", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ConnectICS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r)
|
||||||
|
var in struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawURL := strings.TrimSpace(in.URL)
|
||||||
|
if strings.HasPrefix(rawURL, "webcal://") {
|
||||||
|
rawURL = "https://" + strings.TrimPrefix(rawURL, "webcal://")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Der Link muss mit http://, https:// oder webcal:// beginnen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einmal direkt abrufen, um den Link zu validieren.
|
||||||
|
prov := calendar.NewICSProvider(calendar.ICSConnConfig{URL: rawURL}, user.Timezone)
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := prov.FetchBusy(ctx, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour)); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "ICS-Link prüfen: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.DisplayName == "" {
|
||||||
|
if u, err := url.Parse(rawURL); err == nil {
|
||||||
|
in.DisplayName = u.Host
|
||||||
|
} else {
|
||||||
|
in.DisplayName = "ICS-Kalender"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enc, err := s.encryptConfig(calendar.ICSConnConfig{URL: rawURL})
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := s.DB.CreateConnection(user.ID, "ics", in.DisplayName, enc)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Syncer.SyncInBackground(user)
|
||||||
|
writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "ics", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GoogleConnectStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.Cfg.GoogleClientID == "" || s.Cfg.GoogleClientSecret == "" {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "Google-Anmeldung ist nicht konfiguriert (GOOGLE_CLIENT_ID und GOOGLE_CLIENT_SECRET in .env setzen)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state, err := s.signJWT(userFrom(r).ID, 10*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "State konnte nicht erzeugt werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
|
||||||
|
s.Cfg.AppURL+"/api/calendars/google/callback")
|
||||||
|
authURL := conf.AuthCodeURL(state,
|
||||||
|
oauth2.AccessTypeOffline,
|
||||||
|
oauth2.SetAuthURLParam("prompt", "consent select_account"),
|
||||||
|
)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"url": authURL})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GoogleConnectCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fail := func(reason string) {
|
||||||
|
http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=error&reason="+url.QueryEscape(reason), http.StatusFound)
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if reason := q.Get("error"); reason != "" {
|
||||||
|
fail("Google-Anmeldung abgelehnt: " + reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, err := s.verifyJWT(q.Get("state"))
|
||||||
|
if err != nil {
|
||||||
|
fail("Ungültiger or abgelaufener Verbindungsversuch – bitte erneut versuchen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.DB.UserByID(uid)
|
||||||
|
if err != nil {
|
||||||
|
fail("Konto nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
|
||||||
|
s.Cfg.AppURL+"/api/calendars/google/callback")
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
tok, err := conf.Exchange(ctx, q.Get("code"))
|
||||||
|
if err != nil {
|
||||||
|
fail("Google-Token-Austausch fehlgeschlagen: "+cleanReason(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
calID, summary, err := calendar.GooglePrimaryCalendar(ctx, conf.Client(ctx, tok))
|
||||||
|
if err != nil {
|
||||||
|
fail("Google-Kalender nicht lesbar: "+cleanReason(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := summary
|
||||||
|
if name == "" {
|
||||||
|
name = "Google Kalender"
|
||||||
|
}
|
||||||
|
enc, err := s.encryptConfig(calendar.GoogleConnConfig{Token: tok, CalendarID: calID})
|
||||||
|
if err != nil {
|
||||||
|
fail("Speichern fehlgeschlagen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.DB.CreateConnection(user.ID, "google", name, enc); err != nil {
|
||||||
|
fail("Verbindung konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Syncer.SyncInBackground(user)
|
||||||
|
http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=ok", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) SyncNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 75*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
s.Syncer.SyncUser(ctx, user)
|
||||||
|
s.ListCalendars(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) encryptConfig(cfg any) (string, error) {
|
||||||
|
data, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return crypto.Encrypt(s.Cfg.EncryptionKey, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanReason(msg string) string {
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
if len(msg) > 200 {
|
||||||
|
msg = msg[:200] + "…"
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
201
backend/internal/httpapi/public.go
Normal file
201
backend/internal/httpapi/public.go
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"wannpassts/internal/calendar"
|
||||||
|
"wannpassts/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
var emailRX = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||||
|
|
||||||
|
type publicInterval struct {
|
||||||
|
Start string `json:"start"`
|
||||||
|
End string `json:"end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
SlotMinutes int `json:"slot_minutes"`
|
||||||
|
DayStartMin int `json:"day_start_min"`
|
||||||
|
DayEndMin int `json:"day_end_min"`
|
||||||
|
HorizonDays int `json:"horizon_days"`
|
||||||
|
Durations []int `json:"durations"`
|
||||||
|
Weekdays []int `json:"weekdays"`
|
||||||
|
Busy []publicInterval `json:"busy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicInfo liefert die Buchungsseite: Name, Buchungsregeln und ausschließlich
|
||||||
|
// busy-Zeiträume – niemals Termintitel, Beschreibungen oder Teilnehmer.
|
||||||
|
func (s *Server) PublicInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := s.DB.UserBySlug(chi.URLParam(r, "slug"))
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusNotFound, "Buchungsseite nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if conns, err := s.DB.ConnectionsForUser(user.ID); err == nil && calendar.NeedsSync(conns, now) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
s.Syncer.SyncUser(ctx, user)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
horizonEnd := now.Add(time.Duration(user.HorizonDays+1) * 24 * time.Hour)
|
||||||
|
busy, err := s.DB.BusyForUser(user.ID, now.Add(-time.Hour).Unix(), horizonEnd.Unix())
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Belegung nicht lesbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := publicInfo{
|
||||||
|
Name: user.Name, Timezone: user.Timezone,
|
||||||
|
SlotMinutes: user.SlotMinutes, DayStartMin: user.DayStartMin, DayEndMin: user.DayEndMin,
|
||||||
|
HorizonDays: user.HorizonDays,
|
||||||
|
Durations: csvInts(user.Durations), Weekdays: csvInts(user.Weekdays),
|
||||||
|
Busy: make([]publicInterval, 0, len(busy)),
|
||||||
|
}
|
||||||
|
for _, iv := range mergeBusy(busy) {
|
||||||
|
resp.Busy = append(resp.Busy, publicInterval{
|
||||||
|
Start: time.Unix(iv.StartTs, 0).UTC().Format(time.RFC3339),
|
||||||
|
End: time.Unix(iv.EndTs, 0).UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeBusy(in []db.BusySlot) []db.BusySlot {
|
||||||
|
var out []db.BusySlot
|
||||||
|
for _, s := range in {
|
||||||
|
if n := len(out); n > 0 && s.StartTs <= out[n-1].EndTs {
|
||||||
|
if s.EndTs > out[n-1].EndTs {
|
||||||
|
out[n-1].EndTs = s.EndTs
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBooking nimmt eine Buchungsanfrage an: Validiert gegen Buchungsregeln
|
||||||
|
// und Belegung, legt die Anfrage mit Status "pending" an.
|
||||||
|
func (s *Server) CreateBooking(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := s.DB.UserBySlug(chi.URLParam(r, "slug"))
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusNotFound, "Buchungsseite nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Start string `json:"start"`
|
||||||
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
in.Email = strings.TrimSpace(in.Email)
|
||||||
|
in.Message = strings.TrimSpace(in.Message)
|
||||||
|
if in.Name == "" || len(in.Name) > 100 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 100 Zeichen) angeben")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !emailRX.MatchString(in.Email) || len(in.Email) > 254 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(in.Message) > 2000 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Nachricht zu lang (max. 2000 Zeichen)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := false
|
||||||
|
for _, d := range csvInts(user.Durations) {
|
||||||
|
if d == in.DurationMinutes {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Ungültige Dauer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
start, err := time.Parse(time.RFC3339, strings.TrimSpace(in.Start))
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Ungültiger Startzeitpunkt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
end := start.Add(time.Duration(in.DurationMinutes) * time.Minute)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if start.Unix() < now.Unix()-120 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Der Zeitpunkt liegt in der Vergangenheit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if start.After(now.Add(time.Duration(user.HorizonDays) * 24 * time.Hour)) {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Der Zeitpunkt liegt außerhalb des Buchungszeitraums")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, _ := time.LoadLocation(user.Timezone)
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.UTC
|
||||||
|
}
|
||||||
|
local := start.In(loc)
|
||||||
|
isoWeekday := (int(local.Weekday())+6)%7 + 1 // Mo=1 … So=7
|
||||||
|
if !containsInt(csvInts(user.Weekdays), isoWeekday) {
|
||||||
|
jsonError(w, http.StatusBadRequest, "An diesem Tag werden keine Buchungen angenommen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
minuteOfDay := local.Hour()*60 + local.Minute()
|
||||||
|
if minuteOfDay < user.DayStartMin-2 || minuteOfDay+in.DurationMinutes > user.DayEndMin+2 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Außerhalb der Buchungszeiten")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
busy, err := s.DB.BusyForUser(user.ID, start.Unix()-1, end.Unix()+1)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Belegung nicht prüfbar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, b := range busy {
|
||||||
|
if b.StartTs < end.Unix() && start.Unix() < b.EndTs {
|
||||||
|
jsonError(w, http.StatusConflict, "Dieser Zeitraum ist leider bereits belegt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
booking := &db.Booking{
|
||||||
|
UserID: user.ID, StartTs: start.Unix(), EndTs: end.Unix(),
|
||||||
|
RequesterName: in.Name, RequesterEmail: in.Email, Message: in.Message,
|
||||||
|
}
|
||||||
|
if err := s.DB.CreateBooking(booking); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "Anfrage konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
booking.Status = "pending"
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"id": booking.ID, "status": booking.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsInt(list []int, v int) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
280
backend/internal/httpapi/server.go
Normal file
280
backend/internal/httpapi/server.go
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"wannpassts/internal/calendar"
|
||||||
|
"wannpassts/internal/config"
|
||||||
|
"wannpassts/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
DB *db.DB
|
||||||
|
Cfg *config.Config
|
||||||
|
Syncer *calendar.Syncer
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(database *db.DB, cfg *config.Config, syncer *calendar.Syncer) *Server {
|
||||||
|
return &Server{DB: database, Cfg: cfg, Syncer: syncer}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const ctxUser ctxKey = 1
|
||||||
|
|
||||||
|
func (s *Server) Router() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RequestID, middleware.RealIP, middleware.Logger, middleware.Recoverer)
|
||||||
|
r.Use(cors.Handler(cors.Options{
|
||||||
|
AllowedOrigins: allowedOrigins(s.Cfg.FrontendURL),
|
||||||
|
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
|
||||||
|
AllowedHeaders: []string{"Authorization", "Content-Type"},
|
||||||
|
MaxAge: 300,
|
||||||
|
}))
|
||||||
|
r.Get("/api/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/api", func(api chi.Router) {
|
||||||
|
api.Post("/auth/register", s.Register)
|
||||||
|
api.Post("/auth/login", s.Login)
|
||||||
|
|
||||||
|
api.Get("/calendars/google/callback", s.GoogleConnectCallback)
|
||||||
|
api.Get("/public/{slug}", s.PublicInfo)
|
||||||
|
api.Post("/public/{slug}/bookings", s.CreateBooking)
|
||||||
|
|
||||||
|
api.Group(func(prt chi.Router) {
|
||||||
|
prt.Use(s.AuthMiddleware)
|
||||||
|
prt.Get("/me", s.Me)
|
||||||
|
prt.Patch("/me", s.UpdateMe)
|
||||||
|
prt.Post("/me/slug", s.RegenSlug)
|
||||||
|
prt.Get("/calendars", s.ListCalendars)
|
||||||
|
prt.Delete("/calendars/{id}", s.DeleteCalendar)
|
||||||
|
prt.Post("/calendars/caldav", s.ConnectCalDAV)
|
||||||
|
prt.Post("/calendars/ics", s.ConnectICS)
|
||||||
|
prt.Get("/calendars/google/start", s.GoogleConnectStart)
|
||||||
|
prt.Post("/calendars/sync", s.SyncNow)
|
||||||
|
prt.Get("/bookings", s.ListBookings)
|
||||||
|
prt.Post("/bookings/{id}/accept", s.AcceptBooking)
|
||||||
|
prt.Post("/bookings/{id}/decline", s.DeclineBooking)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if s.Cfg.StaticDir != "" {
|
||||||
|
if st, err := os.Stat(s.Cfg.StaticDir); err == nil && st.IsDir() {
|
||||||
|
static := s.Cfg.StaticDir
|
||||||
|
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
p := filepath.Join(static, filepath.Clean(req.URL.Path))
|
||||||
|
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||||
|
http.ServeFile(w, req, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, req, filepath.Join(static, "index.html"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowedOrigins(frontendURL string) []string {
|
||||||
|
origins := []string{"http://localhost:5173", "http://127.0.0.1:5173"}
|
||||||
|
for _, o := range strings.Split(frontendURL, ",") {
|
||||||
|
o = strings.TrimSpace(o)
|
||||||
|
if o != "" && !contains(origins, o) {
|
||||||
|
origins = append(origins, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origins
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, v string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth Middleware & JWT ---
|
||||||
|
|
||||||
|
func (s *Server) signJWT(userID int64, ttl time.Duration) (string, error) {
|
||||||
|
claims := jwt.RegisteredClaims{
|
||||||
|
Subject: strconv.FormatInt(userID, 10),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
|
||||||
|
}
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return tok.SignedString([]byte(s.Cfg.JWTSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) verifyJWT(tokenStr string) (int64, error) {
|
||||||
|
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("unerwartete Signaturmethode")
|
||||||
|
}
|
||||||
|
return []byte(s.Cfg.JWTSecret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return 0, errors.New("Token ungültig oder abgelaufen")
|
||||||
|
}
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return 0, errors.New("Claims unlesbar")
|
||||||
|
}
|
||||||
|
sub, _ := claims["sub"].(string)
|
||||||
|
id, err := strconv.ParseInt(sub, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.New("Subject unlesbar")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(h, "Bearer ") {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "Nicht angemeldet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, err := s.verifyJWT(strings.TrimSpace(h[len("Bearer "):]))
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "Sitzung ungültig oder abgelaufen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.DB.UserByID(uid)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "Konto nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, user)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func userFrom(r *http.Request) *db.User {
|
||||||
|
return r.Context().Value(ctxUser).(*db.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON helpers ---
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if v != nil {
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBody(r *http.Request, v any) error {
|
||||||
|
defer r.Body.Close()
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||||
|
return dec.Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathID(r *http.Request) (int64, error) {
|
||||||
|
return strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON Darstellungen ---
|
||||||
|
|
||||||
|
type UserJSON struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
SlotMinutes int `json:"slot_minutes"`
|
||||||
|
DayStartMin int `json:"day_start_min"`
|
||||||
|
DayEndMin int `json:"day_end_min"`
|
||||||
|
HorizonDays int `json:"horizon_days"`
|
||||||
|
Durations []int `json:"durations"`
|
||||||
|
Weekdays []int `json:"weekdays"`
|
||||||
|
CreatedTs int64 `json:"created_ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func userToJSON(u *db.User) UserJSON {
|
||||||
|
return UserJSON{
|
||||||
|
ID: u.ID, Email: u.Email, Name: u.Name, Slug: u.Slug, Timezone: u.Timezone,
|
||||||
|
SlotMinutes: u.SlotMinutes, DayStartMin: u.DayStartMin, DayEndMin: u.DayEndMin,
|
||||||
|
HorizonDays: u.HorizonDays, Durations: csvInts(u.Durations), Weekdays: csvInts(u.Weekdays),
|
||||||
|
CreatedTs: u.CreatedTs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func csvInts(csv string) []int {
|
||||||
|
var out []int
|
||||||
|
for _, p := range strings.Split(csv, ",") {
|
||||||
|
if v, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func intsCSV(list []int) string {
|
||||||
|
parts := make([]string, len(list))
|
||||||
|
for i, v := range list {
|
||||||
|
parts[i] = strconv.Itoa(v)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectionJSON struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
LastSyncedTs *int64 `json:"last_synced_ts"`
|
||||||
|
LastError *string `json:"last_error"`
|
||||||
|
CreatedTs int64 `json:"created_ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func connToJSON(c *db.Connection) ConnectionJSON {
|
||||||
|
out := ConnectionJSON{ID: c.ID, Provider: c.Provider, DisplayName: c.DisplayName, CreatedTs: c.CreatedTs}
|
||||||
|
if c.LastSyncedTs.Valid {
|
||||||
|
v := c.LastSyncedTs.Int64
|
||||||
|
out.LastSyncedTs = &v
|
||||||
|
}
|
||||||
|
if c.LastError.Valid && c.LastError.String != "" {
|
||||||
|
v := c.LastError.String
|
||||||
|
out.LastError = &v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookingJSON struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Start string `json:"start"`
|
||||||
|
End string `json:"end"`
|
||||||
|
RequesterName string `json:"requester_name"`
|
||||||
|
RequesterEmail string `json:"requester_email"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedTs int64 `json:"created_ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func bookingToJSON(b *db.Booking) BookingJSON {
|
||||||
|
return BookingJSON{
|
||||||
|
ID: b.ID,
|
||||||
|
Start: time.Unix(b.StartTs, 0).UTC().Format(time.RFC3339),
|
||||||
|
End: time.Unix(b.EndTs, 0).UTC().Format(time.RFC3339),
|
||||||
|
RequesterName: b.RequesterName, RequesterEmail: b.RequesterEmail,
|
||||||
|
Message: b.Message, Status: b.Status, CreatedTs: b.CreatedTs,
|
||||||
|
}
|
||||||
|
}
|
||||||
24
frontend/index.html
Normal file
24
frontend/index.html
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#05070c" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="WannPassts – Deine freien Zeiten, geteilt per Link. Verbinde Google-, iCloud- und andere Kalender und nimm Buchungsanfragen an, ohne deine Termine preiszugeben."
|
||||||
|
/>
|
||||||
|
<title>WannPassts</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1659
frontend/package-lock.json
generated
Normal file
1659
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "wannpassts-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^3.0.2",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vue-tsc": "^2.2.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/public/favicon.svg
Normal file
12
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7db0ff"/>
|
||||||
|
<stop offset="1" stop-color="#b78cff"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="4" y="6" width="40" height="38" rx="11" fill="#0b0f1a" stroke="url(#g)" stroke-width="2.5"/>
|
||||||
|
<rect x="4" y="6" width="40" height="10" rx="5" fill="url(#g)" opacity="0.35"/>
|
||||||
|
<path d="M15 3.5v6M33 3.5v6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M15 26.5l6 6 12-12" stroke="url(#g)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 659 B |
34
frontend/src/App.vue
Normal file
34
frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useAuth } from './stores/auth'
|
||||||
|
import { useToast } from './stores/toast'
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
auth.init()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-scene" aria-hidden="true">
|
||||||
|
<div class="orb orb-1"></div>
|
||||||
|
<div class="orb orb-2"></div>
|
||||||
|
<div class="orb orb-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<router-view />
|
||||||
|
|
||||||
|
<transition-group name="fade" tag="div" class="toasts">
|
||||||
|
<div
|
||||||
|
v-for="t in toast.toasts"
|
||||||
|
:key="t.id"
|
||||||
|
class="toast glass"
|
||||||
|
:class="{ 'toast-ok': t.kind === 'ok', 'toast-error': t.kind === 'error' }"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ t.message }}
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
</template>
|
||||||
36
frontend/src/components/GlassCard.vue
Normal file
36
frontend/src/components/GlassCard.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title?: string; subtitle?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="glass card">
|
||||||
|
<header v-if="title || subtitle || $slots.header" class="card-head">
|
||||||
|
<div class="grow">
|
||||||
|
<h2 v-if="title">{{ title }}</h2>
|
||||||
|
<p v-if="subtitle" class="muted small" style="margin: 4px 0 0">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<slot name="header" />
|
||||||
|
</header>
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
padding: 22px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
frontend/src/components/LogoMark.vue
Normal file
22
frontend/src/components/LogoMark.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<template>
|
||||||
|
<svg class="logo" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="wp-lg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7db0ff" />
|
||||||
|
<stop offset="1" stop-color="#b78cff" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="4.75" y="6.75" width="38.5" height="36.5" rx="10.5" fill="rgba(255,255,255,0.06)" stroke="url(#wp-lg)" stroke-width="2.2" />
|
||||||
|
<path d="M5 16h38" stroke="url(#wp-lg)" stroke-width="2.2" opacity="0.7" />
|
||||||
|
<path d="M15 3.5v6M33 3.5v6" stroke="url(#wp-lg)" stroke-width="3" stroke-linecap="round" />
|
||||||
|
<path d="M15 27l6.5 6.5L33.5 21" stroke="url(#wp-lg)" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logo {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
filter: drop-shadow(0 4px 14px rgba(125, 176, 255, 0.35));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
239
frontend/src/components/SectionConnections.vue
Normal file
239
frontend/src/components/SectionConnections.vue
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { ApiError, api } from '../lib/api'
|
||||||
|
import type { Connection } from '../lib/types'
|
||||||
|
import { useToast } from '../stores/toast'
|
||||||
|
import GlassCard from './GlassCard.vue'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const connections = ref<Connection[] | null>(null)
|
||||||
|
const syncing = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
|
||||||
|
const showCalDAV = ref(false)
|
||||||
|
const showICS = ref(false)
|
||||||
|
|
||||||
|
const caldav = ref({ server_url: 'https://caldav.icloud.com', username: '', password: '', calendar_path: '', display_name: '' })
|
||||||
|
const ics = ref({ url: '', display_name: '' })
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const providerLabel: Record<Connection['provider'], string> = {
|
||||||
|
google: 'Google',
|
||||||
|
caldav: 'CalDAV / iCloud',
|
||||||
|
ics: 'ICS-Abo',
|
||||||
|
}
|
||||||
|
const providerIcon: Record<Connection['provider'], string> = {
|
||||||
|
google: '🇬',
|
||||||
|
caldav: '☁️',
|
||||||
|
ics: '📡',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
connections.value = await api.connections()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Laden fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll() {
|
||||||
|
pollTimer = setTimeout(load, 3500)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
schedulePoll()
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer) clearTimeout(pollTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function syncNow() {
|
||||||
|
syncing.value = true
|
||||||
|
try {
|
||||||
|
connections.value = await api.syncNow()
|
||||||
|
toast.ok('Kalender synchronisiert')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Sync fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
syncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectGoogle() {
|
||||||
|
try {
|
||||||
|
const { url } = await api.googleStart()
|
||||||
|
location.href = url
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Google-Start fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCalDAV() {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await api.connectCalDAV({
|
||||||
|
server_url: caldav.value.server_url,
|
||||||
|
username: caldav.value.username,
|
||||||
|
password: caldav.value.password,
|
||||||
|
calendar_path: caldav.value.calendar_path || undefined,
|
||||||
|
display_name: caldav.value.display_name || undefined,
|
||||||
|
})
|
||||||
|
toast.ok('Kalender verbunden – erste Synchronisierung läuft.')
|
||||||
|
showCalDAV.value = false
|
||||||
|
caldav.value.username = ''
|
||||||
|
caldav.value.password = ''
|
||||||
|
await load()
|
||||||
|
schedulePoll()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Verbinden fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitICS() {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await api.connectICS({ url: ics.value.url, display_name: ics.value.display_name || undefined })
|
||||||
|
toast.ok('ICS-Kalender verbunden.')
|
||||||
|
showICS.value = false
|
||||||
|
ics.value.url = ''
|
||||||
|
ics.value.display_name = ''
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Verbinden fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(conn: Connection) {
|
||||||
|
if (!confirm(`„${conn.display_name}“ wirklich entfernen?`)) return
|
||||||
|
try {
|
||||||
|
await api.deleteConnection(conn.id)
|
||||||
|
connections.value = (connections.value ?? []).filter((c) => c.id !== conn.id)
|
||||||
|
toast.ok('Verbindung entfernt')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Löschen fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncedAgo(ts: number | null): string {
|
||||||
|
if (!ts) return 'wird synchronisiert…'
|
||||||
|
const s = Math.max(0, Math.round(Date.now() / 1000 - ts))
|
||||||
|
if (s < 60) return `vor ${s}s`
|
||||||
|
if (s < 3600) return `vor ${Math.round(s / 60)} Min.`
|
||||||
|
return `vor ${Math.round(s / 3600)} Std.`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="col">
|
||||||
|
<GlassCard title="Kalender hinzufügen" subtitle="WannPassts liest nur frei/belegt – nie Termindetails.">
|
||||||
|
<div class="row add-row">
|
||||||
|
<button class="btn" @click="connectGoogle">🇬 Google Kalender</button>
|
||||||
|
<button class="btn" @click="showCalDAV = !showCalDAV; showICS = false">☁️ iCloud / CalDAV</button>
|
||||||
|
<button class="btn" @click="showICS = !showICS; showCalDAV = false">📡 ICS-Link</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<form v-if="showCalDAV" class="glass-soft form" @submit.prevent="submitCalDAV">
|
||||||
|
<p class="muted small" style="margin: 0 0 4px">
|
||||||
|
Für iCloud: <strong>Apple-ID</strong> als Benutzername und ein
|
||||||
|
<a href="https://appleid.apple.com/account/manage" target="_blank" rel="noopener">App-spezifisches Passwort</a>
|
||||||
|
(Konto → Anmeldung und Sicherheit). Auch Fastmail, Nextcloud & Co. funktionieren.
|
||||||
|
</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>Server</span>
|
||||||
|
<input v-model="caldav.server_url" required class="input" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Benutzername</span>
|
||||||
|
<input v-model="caldav.username" required class="input" autocomplete="username" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Passwort (App-spezifisch)</span>
|
||||||
|
<input v-model="caldav.password" type="password" required class="input" autocomplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Kalender-Pfad (optional)</span>
|
||||||
|
<input v-model="caldav.calendar_path" class="input" placeholder="wird automatisch erkannt" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Anzeigename (optional)</span>
|
||||||
|
<input v-model="caldav.display_name" class="input" placeholder="z. B. Privatkalender" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit" :disabled="busy">
|
||||||
|
{{ busy ? 'Verbinde…' : 'Verbinden' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<form v-if="showICS" class="glass-soft form" @submit.prevent="submitICS">
|
||||||
|
<p class="muted small" style="margin: 0 0 4px">
|
||||||
|
Funktioniert mit jedem öffentlichen ICS/webcal-Link, z. B. der „privaten Adresse im
|
||||||
|
iCal-Format“ eines Google-Kalenders oder Ferien-/Schichtplänen.
|
||||||
|
</p>
|
||||||
|
<label class="field">
|
||||||
|
<span>ICS-URL</span>
|
||||||
|
<input v-model="ics.url" required class="input" placeholder="https://…/kalender.ics" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Anzeigename (optional)</span>
|
||||||
|
<input v-model="ics.display_name" class="input" placeholder="z. B. Schichtplan" />
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary" type="submit" :disabled="busy">
|
||||||
|
{{ busy ? 'Prüfe…' : 'Hinzufügen' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</transition>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard title="Verbundene Kalender">
|
||||||
|
<template #header>
|
||||||
|
<button class="btn btn-sm" :disabled="syncing" @click="syncNow">
|
||||||
|
{{ syncing ? 'Synchronisiere…' : '↻ Jetzt synchronisieren' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="connections === null" class="skeleton" style="height: 72px"></div>
|
||||||
|
<p v-else-if="connections.length === 0" class="muted small" style="margin: 0">
|
||||||
|
Noch kein Kalender verbunden. Oben einen hinzufügen – ohne Kalender sind alle Zeiten frei.
|
||||||
|
</p>
|
||||||
|
<div v-else class="col conn-list">
|
||||||
|
<div v-for="c in connections" :key="c.id" class="glass-soft conn">
|
||||||
|
<span class="conn-icon">{{ providerIcon[c.provider] }}</span>
|
||||||
|
<div class="grow">
|
||||||
|
<div class="row">
|
||||||
|
<strong>{{ c.display_name }}</strong>
|
||||||
|
<span class="badge">{{ providerLabel[c.provider] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row small">
|
||||||
|
<span :class="c.last_error ? 'err' : 'muted'">
|
||||||
|
{{ c.last_error ? '⚠ ' + c.last_error : '✓ synchronisiert ' + syncedAgo(c.last_synced_ts) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-danger" @click="remove(c)">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.add-row { flex-wrap: wrap; }
|
||||||
|
.form { padding: 18px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||||
|
.form a { color: var(--accent); }
|
||||||
|
.conn { display: flex; align-items: center; gap: 14px; padding: 14px 16px; }
|
||||||
|
.conn-icon { font-size: 22px; }
|
||||||
|
.err { color: var(--danger); }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
104
frontend/src/components/SectionRequests.vue
Normal file
104
frontend/src/components/SectionRequests.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { ApiError, api } from '../lib/api'
|
||||||
|
import type { Booking } from '../lib/types'
|
||||||
|
import { useAuth } from '../stores/auth'
|
||||||
|
import { useToast } from '../stores/toast'
|
||||||
|
import { formatRange } from '../lib/tz'
|
||||||
|
import GlassCard from './GlassCard.vue'
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
const bookings = ref<Booking[] | null>(null)
|
||||||
|
const busyId = ref<number | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
bookings.value = await api.bookings()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
|
auth.logout()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Laden fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
async function accept(b: Booking) {
|
||||||
|
busyId.value = b.id
|
||||||
|
try {
|
||||||
|
await api.acceptBooking(b.id)
|
||||||
|
toast.ok('Anfrage angenommen – Zeitraum ist jetzt belegt.')
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Annehmen fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
busyId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decline(b: Booking) {
|
||||||
|
busyId.value = b.id
|
||||||
|
try {
|
||||||
|
await api.declineBooking(b.id)
|
||||||
|
toast.push('Anfrage abgelehnt.', 'info')
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Ablehnen fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
busyId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = { pending: 'Offen', accepted: 'Angenommen', declined: 'Abgelehnt' } as const
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<GlassCard title="Buchungsanfragen" :subtitle="`Zeitzone: ${auth.user?.timezone ?? ''} · Zeiten aus deinen Buchungsregeln`">
|
||||||
|
<div v-if="bookings === null" class="skeleton" style="height: 90px"></div>
|
||||||
|
<p v-else-if="bookings.length === 0" class="muted small" style="margin: 0">
|
||||||
|
Noch keine Anfragen. Teile deinen Buchungslink, damit es losgehen kann ✨
|
||||||
|
</p>
|
||||||
|
<div v-else class="col">
|
||||||
|
<article v-for="b in bookings" :key="b.id" class="glass-soft req" :class="{ dimmed: b.status !== 'pending' }">
|
||||||
|
<div class="req-head">
|
||||||
|
<div class="grow">
|
||||||
|
<div class="row">
|
||||||
|
<strong>{{ b.requester_name }}</strong>
|
||||||
|
<span class="badge" :class="`badge-${b.status}`">{{ statusLabel[b.status] }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="faint small">{{ b.requester_email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="req-actions">
|
||||||
|
<template v-if="b.status === 'pending'">
|
||||||
|
<button class="btn btn-sm btn-primary" :disabled="busyId === b.id" @click="accept(b)">Annehmen</button>
|
||||||
|
<button class="btn btn-sm btn-danger" :disabled="busyId === b.id" @click="decline(b)">Ablehnen</button>
|
||||||
|
</template>
|
||||||
|
<button v-else-if="b.status === 'accepted'" class="btn btn-sm" :disabled="busyId === b.id" @click="decline(b)">
|
||||||
|
Doch ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="time">🕒 {{ formatRange(b.start, b.end, auth.user?.timezone ?? 'UTC') }}</div>
|
||||||
|
<p v-if="b.message" class="msg">{{ b.message }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.req { padding: 16px 18px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.req.dimmed { opacity: 0.6; }
|
||||||
|
.req-head { display: flex; align-items: flex-start; gap: 12px; }
|
||||||
|
.req-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.time { font-size: 15px; font-weight: 600; color: var(--accent); }
|
||||||
|
.msg {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
155
frontend/src/components/SectionSettings.vue
Normal file
155
frontend/src/components/SectionSettings.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import { ApiError, api } from '../lib/api'
|
||||||
|
import { useAuth } from '../stores/auth'
|
||||||
|
import { useToast } from '../stores/toast'
|
||||||
|
import GlassCard from './GlassCard.vue'
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
const user = auth.user!
|
||||||
|
|
||||||
|
const weekdayNames = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
|
||||||
|
|
||||||
|
function minutesToHHMM(m: number): string {
|
||||||
|
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
function hhmmToMinutes(s: string): number {
|
||||||
|
const [h, m] = s.split(':').map((v) => parseInt(v, 10))
|
||||||
|
return (isNaN(h) ? 0 : h) * 60 + (isNaN(m) ? 0 : m)
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: user.name,
|
||||||
|
timezone: user.timezone,
|
||||||
|
slot_minutes: user.slot_minutes,
|
||||||
|
day_start: minutesToHHMM(user.day_start_min),
|
||||||
|
day_end: minutesToHHMM(user.day_end_min),
|
||||||
|
horizon_days: user.horizon_days,
|
||||||
|
durations: user.durations.join(', '),
|
||||||
|
weekdays: new Set(user.weekdays),
|
||||||
|
})
|
||||||
|
|
||||||
|
let saving = false
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (saving) return
|
||||||
|
saving = true
|
||||||
|
try {
|
||||||
|
const durations = form.durations
|
||||||
|
.split(',')
|
||||||
|
.map((s) => parseInt(s.trim(), 10))
|
||||||
|
.filter((n) => !isNaN(n))
|
||||||
|
const res = await api.updateMe({
|
||||||
|
name: form.name,
|
||||||
|
timezone: form.timezone,
|
||||||
|
slot_minutes: form.slot_minutes,
|
||||||
|
day_start_min: hhmmToMinutes(form.day_start),
|
||||||
|
day_end_min: hhmmToMinutes(form.day_end),
|
||||||
|
horizon_days: form.horizon_days,
|
||||||
|
durations,
|
||||||
|
weekdays: [...form.weekdays].sort((a, b) => a - b),
|
||||||
|
})
|
||||||
|
auth.setUser(res.user)
|
||||||
|
toast.ok('Einstellungen gespeichert')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Speichern fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newSlug() {
|
||||||
|
if (!confirm('Neuen Link erzeugen? Der alte Link funktioniert dann nicht mehr.')) return
|
||||||
|
try {
|
||||||
|
const res = await api.regenSlug()
|
||||||
|
auth.setUser(res.user)
|
||||||
|
toast.ok('Neuer Buchungslink erzeugt')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Fehler beim Erneuern')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWeekday(n: number) {
|
||||||
|
if (form.weekdays.has(n)) form.weekdays.delete(n)
|
||||||
|
else form.weekdays.add(n)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="col">
|
||||||
|
<GlassCard title="Buchungsregeln" subtitle="Diese Regeln gelten für deine öffentliche Buchungsseite.">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>Anzeigename</span>
|
||||||
|
<input v-model="form.name" class="input" maxlength="80" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Zeitzone (IANA, z. B. Europe/Berlin)</span>
|
||||||
|
<input v-model="form.timezone" class="input" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Buchbar ab / bis (Uhrzeit)</span>
|
||||||
|
<div class="row">
|
||||||
|
<input v-model="form.day_start" type="time" class="input" required />
|
||||||
|
<span class="muted">–</span>
|
||||||
|
<input v-model="form.day_end" type="time" class="input" required />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Slot-Raster (Minuten)</span>
|
||||||
|
<select v-model.number="form.slot_minutes" class="input">
|
||||||
|
<option :value="10">10</option>
|
||||||
|
<option :value="15">15</option>
|
||||||
|
<option :value="20">20</option>
|
||||||
|
<option :value="30">30</option>
|
||||||
|
<option :value="60">60</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Horizont (Tage im Voraus)</span>
|
||||||
|
<input v-model.number="form.horizon_days" type="number" min="1" max="120" class="input" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Mögliche Dauern (Minuten, kommagetrennt)</span>
|
||||||
|
<input v-model="form.durations" class="input" placeholder="15, 30, 60, 120" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="small muted" style="font-weight: 500">Buchbare Wochentage</span>
|
||||||
|
<div class="row weekdays">
|
||||||
|
<button
|
||||||
|
v-for="(name, i) in weekdayNames"
|
||||||
|
:key="i"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm weekday"
|
||||||
|
:class="{ on: form.weekdays.has(i + 1) }"
|
||||||
|
@click="toggleWeekday(i + 1)"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn btn-primary" @click="save">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard title="Buchungslink erneuern" subtitle="Erzeugt einen neuen Slug – der alte Link verfällt sofort.">
|
||||||
|
<button class="btn btn-danger" @click="newSlug">Neuen Link generieren</button>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; }
|
||||||
|
.weekdays { flex-wrap: wrap; }
|
||||||
|
.weekday { opacity: 0.55; }
|
||||||
|
.weekday.on {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: rgba(125, 176, 255, 0.6);
|
||||||
|
background: rgba(125, 176, 255, 0.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
frontend/src/components/ShareLinkCard.vue
Normal file
46
frontend/src/components/ShareLinkCard.vue
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useAuth } from '../stores/auth'
|
||||||
|
import { useToast } from '../stores/toast'
|
||||||
|
import GlassCard from './GlassCard.vue'
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
const link = computed(() => `${location.origin}/b/${auth.user?.slug ?? ''}`)
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link.value)
|
||||||
|
copied.value = true
|
||||||
|
toast.ok('Link kopiert!')
|
||||||
|
setTimeout(() => (copied.value = false), 2000)
|
||||||
|
} catch {
|
||||||
|
toast.error('Kopieren nicht möglich – bitte manuell markieren.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<GlassCard title="🔗 Dein Buchungslink" subtitle="Teile diesen Link – andere sehen nur frei/belegt und können anfragen.">
|
||||||
|
<div class="row link-row">
|
||||||
|
<code class="link glass-soft grow">{{ link }}</code>
|
||||||
|
<button class="btn btn-primary" @click="copy">{{ copied ? 'Kopiert ✓' : 'Kopieren' }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="faint small" style="margin: 0">
|
||||||
|
Tipp: QR-Code oder Link in Signatur, Website oder Nachrichtenaustausch einbauen.
|
||||||
|
</p>
|
||||||
|
</GlassCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.link-row { flex-wrap: wrap; }
|
||||||
|
.link {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
frontend/src/env.d.ts
vendored
Normal file
7
frontend/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
109
frontend/src/lib/api.ts
Normal file
109
frontend/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import type { Booking, Connection, PublicInfo, User } from './types'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'wannpassts_token'
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message)
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
export function setToken(token: string) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
}
|
||||||
|
export function clearToken() {
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
|
const token = getToken()
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch('/api' + path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new ApiError('Server nicht erreichbar – läuft das Backend?', 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
let data: any = null
|
||||||
|
try {
|
||||||
|
data = await res.json()
|
||||||
|
} catch {
|
||||||
|
/* leere Antwort */
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(data?.error ?? `Fehler ${res.status}`, res.status)
|
||||||
|
}
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Auth
|
||||||
|
register(payload: { email: string; password: string; name: string; timezone?: string }) {
|
||||||
|
return request<{ token: string; user: User }>('POST', '/auth/register', payload)
|
||||||
|
},
|
||||||
|
login(payload: { email: string; password: string }) {
|
||||||
|
return request<{ token: string; user: User }>('POST', '/auth/login', payload)
|
||||||
|
},
|
||||||
|
me() {
|
||||||
|
return request<{ user: User }>('GET', '/me')
|
||||||
|
},
|
||||||
|
updateMe(payload: Partial<Pick<User, 'name' | 'timezone' | 'slot_minutes' | 'day_start_min' | 'day_end_min' | 'horizon_days'>> & { durations?: number[]; weekdays?: number[] }) {
|
||||||
|
return request<{ user: User }>('PATCH', '/me', payload)
|
||||||
|
},
|
||||||
|
regenSlug() {
|
||||||
|
return request<{ user: User }>('POST', '/me/slug')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Kalender
|
||||||
|
connections() {
|
||||||
|
return request<Connection[]>('GET', '/calendars')
|
||||||
|
},
|
||||||
|
deleteConnection(id: number) {
|
||||||
|
return request<void>('DELETE', `/calendars/${id}`)
|
||||||
|
},
|
||||||
|
connectCalDAV(payload: { server_url: string; username: string; password: string; calendar_path?: string; display_name?: string }) {
|
||||||
|
return request<Connection>('POST', '/calendars/caldav', payload)
|
||||||
|
},
|
||||||
|
connectICS(payload: { url: string; display_name?: string }) {
|
||||||
|
return request<Connection>('POST', '/calendars/ics', payload)
|
||||||
|
},
|
||||||
|
googleStart() {
|
||||||
|
return request<{ url: string }>('GET', '/calendars/google/start')
|
||||||
|
},
|
||||||
|
syncNow() {
|
||||||
|
return request<Connection[]>('POST', '/calendars/sync')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Anfragen
|
||||||
|
bookings() {
|
||||||
|
return request<Booking[]>('GET', '/bookings')
|
||||||
|
},
|
||||||
|
acceptBooking(id: number) {
|
||||||
|
return request<Booking>('POST', `/bookings/${id}/accept`)
|
||||||
|
},
|
||||||
|
declineBooking(id: number) {
|
||||||
|
return request<Booking>('POST', `/bookings/${id}/decline`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Öffentliche Buchungsseite
|
||||||
|
publicInfo(slug: string) {
|
||||||
|
return request<PublicInfo>('GET', `/public/${slug}`)
|
||||||
|
},
|
||||||
|
createBooking(slug: string, payload: { start: string; duration_minutes: number; name: string; email: string; message?: string }) {
|
||||||
|
return request<{ id: number; status: string }>('POST', `/public/${slug}/bookings`, payload)
|
||||||
|
},
|
||||||
|
}
|
||||||
46
frontend/src/lib/types.ts
Normal file
46
frontend/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
timezone: string
|
||||||
|
slot_minutes: number
|
||||||
|
day_start_min: number
|
||||||
|
day_end_min: number
|
||||||
|
horizon_days: number
|
||||||
|
durations: number[]
|
||||||
|
weekdays: number[]
|
||||||
|
created_ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Connection {
|
||||||
|
id: number
|
||||||
|
provider: 'google' | 'caldav' | 'ics'
|
||||||
|
display_name: string
|
||||||
|
last_synced_ts: number | null
|
||||||
|
last_error: string | null
|
||||||
|
created_ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Booking {
|
||||||
|
id: number
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
requester_name: string
|
||||||
|
requester_email: string
|
||||||
|
message: string
|
||||||
|
status: 'pending' | 'accepted' | 'declined'
|
||||||
|
created_ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicInfo {
|
||||||
|
name: string
|
||||||
|
timezone: string
|
||||||
|
slot_minutes: number
|
||||||
|
day_start_min: number
|
||||||
|
day_end_min: number
|
||||||
|
horizon_days: number
|
||||||
|
durations: number[]
|
||||||
|
weekdays: number[]
|
||||||
|
busy: { start: string; end: string }[]
|
||||||
|
}
|
||||||
90
frontend/src/lib/tz.ts
Normal file
90
frontend/src/lib/tz.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Zeitzonen-Helfer: Wanduhrzeiten in einer Zielzone ↔ UTC-Epoch-Millisekunden.
|
||||||
|
|
||||||
|
const partsCache = new Map<string, Intl.DateTimeFormat>()
|
||||||
|
|
||||||
|
function tzPartsFmt(tz: string): Intl.DateTimeFormat {
|
||||||
|
let f = partsCache.get(tz)
|
||||||
|
if (!f) {
|
||||||
|
f = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: tz,
|
||||||
|
hourCycle: 'h23',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
weekday: 'short',
|
||||||
|
})
|
||||||
|
partsCache.set(tz, f)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZonedParts {
|
||||||
|
year: number
|
||||||
|
month: number // 1–12
|
||||||
|
day: number
|
||||||
|
hour: number
|
||||||
|
minute: number
|
||||||
|
weekday: number // ISO: Mo=1 … So=7
|
||||||
|
minuteOfDay: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdayMap: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }
|
||||||
|
|
||||||
|
export function partsInTz(date: Date, tz: string): ZonedParts {
|
||||||
|
const parts = tzPartsFmt(tz).formatToParts(date)
|
||||||
|
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0'
|
||||||
|
const hour = +get('hour') % 24
|
||||||
|
const minute = +get('minute')
|
||||||
|
return {
|
||||||
|
year: +get('year'),
|
||||||
|
month: +get('month'),
|
||||||
|
day: +get('day'),
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
weekday: weekdayMap[get('weekday')] ?? 1,
|
||||||
|
minuteOfDay: hour * 60 + minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offset der Zone zum Zeitpunkt (ms), z. B. +2h für MESZ. */
|
||||||
|
export function tzOffsetMs(date: Date, tz: string): number {
|
||||||
|
const p = partsInTz(date, tz)
|
||||||
|
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, date.getUTCSeconds())
|
||||||
|
return asUtc - date.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wanduhrzeit (y, mo 1–12, d, h, min) in Zone tz → UTC-Epoch-Millisekunden. */
|
||||||
|
export function zonedToUtc(year: number, month: number, day: number, hour: number, minute: number, tz: string): number {
|
||||||
|
const naive = Date.UTC(year, month - 1, day, hour, minute, 0)
|
||||||
|
let ts = naive
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
ts = naive - tzOffsetMs(new Date(ts), tz)
|
||||||
|
}
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(tsMs: number, tz: string): string {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' }).format(new Date(tsMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDayShort(tsMs: number, tz: string): string {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', { timeZone: tz, weekday: 'short', day: '2-digit', month: 'short' }).format(new Date(tsMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRange(startISO: string, endISO: string, tz: string): string {
|
||||||
|
const day = formatDayShort(new Date(startISO).getTime(), tz)
|
||||||
|
const t1 = formatTime(new Date(startISO).getTime(), tz)
|
||||||
|
const t2 = formatTime(new Date(endISO).getTime(), tz)
|
||||||
|
return `${day} · ${t1}–${t2} Uhr`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function guessTimezone(): string {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin'
|
||||||
|
} catch {
|
||||||
|
return 'Europe/Berlin'
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/main.ts
Normal file
10
frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './styles/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
30
frontend/src/router/index.ts
Normal file
30
frontend/src/router/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { getToken } from '../lib/api'
|
||||||
|
import LandingView from '../views/LandingView.vue'
|
||||||
|
import LoginView from '../views/LoginView.vue'
|
||||||
|
import RegisterView from '../views/RegisterView.vue'
|
||||||
|
import DashboardView from '../views/DashboardView.vue'
|
||||||
|
import BookingView from '../views/BookingView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', name: 'landing', component: LandingView },
|
||||||
|
{ path: '/login', name: 'login', component: LoginView },
|
||||||
|
{ path: '/register', name: 'register', component: RegisterView },
|
||||||
|
{ path: '/app', name: 'dashboard', component: DashboardView, meta: { requiresAuth: true } },
|
||||||
|
{ path: '/b/:slug', name: 'booking', component: BookingView },
|
||||||
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.meta.requiresAuth && !getToken()) {
|
||||||
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
if ((to.name === 'login' || to.name === 'register' || to.name === 'landing') && getToken()) {
|
||||||
|
return { name: 'dashboard' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
47
frontend/src/stores/auth.ts
Normal file
47
frontend/src/stores/auth.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { api, clearToken, getToken, setToken } from '../lib/api'
|
||||||
|
import type { User } from '../lib/types'
|
||||||
|
|
||||||
|
export const useAuth = defineStore('auth', () => {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const ready = ref(false)
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (!getToken()) {
|
||||||
|
ready.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.me()
|
||||||
|
user.value = res.user
|
||||||
|
} catch {
|
||||||
|
clearToken()
|
||||||
|
} finally {
|
||||||
|
ready.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(email: string, password: string) {
|
||||||
|
const res = await api.login({ email, password })
|
||||||
|
setToken(res.token)
|
||||||
|
user.value = res.user
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(email: string, password: string, name: string, timezone: string) {
|
||||||
|
const res = await api.register({ email, password, name, timezone })
|
||||||
|
setToken(res.token)
|
||||||
|
user.value = res.user
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUser(u: User) {
|
||||||
|
user.value = u
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearToken()
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, ready, init, login, register, setUser, logout }
|
||||||
|
})
|
||||||
27
frontend/src/stores/toast.ts
Normal file
27
frontend/src/stores/toast.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number
|
||||||
|
message: string
|
||||||
|
kind: 'ok' | 'error' | 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 1
|
||||||
|
|
||||||
|
export const useToast = defineStore('toast', () => {
|
||||||
|
const toasts = ref<Toast[]>([])
|
||||||
|
|
||||||
|
function push(message: string, kind: Toast['kind'] = 'info') {
|
||||||
|
const id = nextId++
|
||||||
|
toasts.value.push({ id, message, kind })
|
||||||
|
setTimeout(() => {
|
||||||
|
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||||
|
}, 4500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = (m: string) => push(m, 'ok')
|
||||||
|
const error = (m: string) => push(m, 'error')
|
||||||
|
|
||||||
|
return { toasts, push, ok, error }
|
||||||
|
})
|
||||||
324
frontend/src/styles/main.css
Normal file
324
frontend/src/styles/main.css
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
/* ─── WannPassts · Liquid Glass Dark ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-0: #05070c;
|
||||||
|
--bg-1: #0a0e18;
|
||||||
|
--text: #eef1f8;
|
||||||
|
--text-muted: rgba(238, 241, 248, 0.58);
|
||||||
|
--text-faint: rgba(238, 241, 248, 0.34);
|
||||||
|
--border: rgba(255, 255, 255, 0.13);
|
||||||
|
--border-soft: rgba(255, 255, 255, 0.08);
|
||||||
|
--accent: #7db0ff;
|
||||||
|
--accent-2: #b78cff;
|
||||||
|
--accent-3: #6fe3c4;
|
||||||
|
--danger: #ff7a8a;
|
||||||
|
--ok: #46e0a5;
|
||||||
|
--radius-lg: 24px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
--radius-sm: 12px;
|
||||||
|
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 800px at 85% -10%, rgba(125, 176, 255, 0.10), transparent 60%),
|
||||||
|
radial-gradient(1000px 700px at -10% 30%, rgba(183, 140, 255, 0.09), transparent 55%),
|
||||||
|
radial-gradient(900px 600px at 50% 110%, rgba(111, 227, 196, 0.06), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--bg-1), var(--bg-0));
|
||||||
|
background-attachment: fixed;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app { min-height: 100vh; }
|
||||||
|
|
||||||
|
/* Farbliche "Orbs" hinter allem – geben dem Glass etwas zum Brechen */
|
||||||
|
.bg-scene { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; }
|
||||||
|
.orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(110px);
|
||||||
|
opacity: 0.45;
|
||||||
|
animation: orb-drift 26s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.orb-1 { width: 46vw; height: 46vw; left: -10vw; top: -14vh; background: radial-gradient(circle, #2b5cc4, transparent 70%); }
|
||||||
|
.orb-2 { width: 40vw; height: 40vw; right: -8vw; top: 22vh; background: radial-gradient(circle, #6d3fb8, transparent 70%); animation-delay: -8s; }
|
||||||
|
.orb-3 { width: 34vw; height: 34vw; left: 28vw; bottom: -18vh; background: radial-gradient(circle, #1a7f68, transparent 70%); animation-delay: -16s; }
|
||||||
|
|
||||||
|
@keyframes orb-drift {
|
||||||
|
from { transform: translate3d(0, 0, 0) scale(1); }
|
||||||
|
to { transform: translate3d(6vw, 5vh, 0) scale(1.12); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.orb { animation: none; }
|
||||||
|
* { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Glass-Bausteine ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: linear-gradient(150deg, rgba(255, 255, 255, 0.085), rgba(255, 255, 255, 0.028));
|
||||||
|
backdrop-filter: blur(26px) saturate(170%);
|
||||||
|
-webkit-backdrop-filter: blur(26px) saturate(170%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-soft {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Layout ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1060px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 20px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row { display: flex; gap: 12px; align-items: center; }
|
||||||
|
.col { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.grow { flex: 1; }
|
||||||
|
.muted { color: var(--text-muted); }
|
||||||
|
.faint { color: var(--text-faint); }
|
||||||
|
.small { font-size: 13px; }
|
||||||
|
|
||||||
|
/* ─── Buttons ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(150deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.04));
|
||||||
|
color: var(--text);
|
||||||
|
font: 600 14px/1 var(--font);
|
||||||
|
padding: 11px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { transform: translateY(-1px); border-color: rgba(255, 255, 255, 0.24); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); }
|
||||||
|
.btn:active { transform: translateY(0); }
|
||||||
|
.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||||
|
border: none;
|
||||||
|
color: #071018;
|
||||||
|
box-shadow: 0 10px 34px rgba(125, 176, 255, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { box-shadow: 0 14px 40px rgba(125, 176, 255, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.35); }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
border-color: rgba(255, 122, 138, 0.4);
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(255, 122, 138, 0.08);
|
||||||
|
}
|
||||||
|
.btn-danger:hover { border-color: rgba(255, 122, 138, 0.7); box-shadow: 0 8px 24px rgba(255, 122, 138, 0.18); }
|
||||||
|
|
||||||
|
.btn-sm { padding: 8px 14px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ─── Formulare ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
label.field { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
label.field > span { font-size: 13px; color: var(--text-muted); font-weight: 500; }
|
||||||
|
|
||||||
|
.input, select.input, textarea.input {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font: 500 14px/1.4 var(--font);
|
||||||
|
padding: 11px 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: rgba(125, 176, 255, 0.65);
|
||||||
|
box-shadow: 0 0 0 3px rgba(125, 176, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
.input::placeholder { color: var(--text-faint); }
|
||||||
|
select.input option { background: #10141f; color: var(--text); }
|
||||||
|
textarea.input { resize: vertical; min-height: 84px; }
|
||||||
|
|
||||||
|
/* ─── Badges & Chips ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.badge-pending { border-color: rgba(125, 176, 255, 0.45); color: var(--accent); background: rgba(125, 176, 255, 0.10); }
|
||||||
|
.badge-accepted { border-color: rgba(70, 224, 165, 0.45); color: var(--ok); background: rgba(70, 224, 165, 0.10); }
|
||||||
|
.badge-declined { border-color: rgba(255, 122, 138, 0.4); color: var(--danger); background: rgba(255, 122, 138, 0.08); }
|
||||||
|
|
||||||
|
/* ─── Tabs ────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: 600 14px/1 var(--font);
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text); }
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: linear-gradient(150deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.06));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 6px 18px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Toasts ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.toasts {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 22px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 100;
|
||||||
|
width: min(480px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
padding: 13px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
animation: toast-in 0.25s ease;
|
||||||
|
}
|
||||||
|
.toast-ok { border-color: rgba(70, 224, 165, 0.5); }
|
||||||
|
.toast-error { border-color: rgba(255, 122, 138, 0.5); }
|
||||||
|
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
/* ─── Slot-Chips (Buchungsseite) ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.slot-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.slot-chip {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: 600 14px/1 var(--font);
|
||||||
|
padding: 12px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.13s ease;
|
||||||
|
}
|
||||||
|
.slot-chip:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: rgba(125, 176, 255, 0.5);
|
||||||
|
background: rgba(125, 176, 255, 0.10);
|
||||||
|
}
|
||||||
|
.slot-chip.selected {
|
||||||
|
color: #071018;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 8px 26px rgba(125, 176, 255, 0.4);
|
||||||
|
}
|
||||||
|
.slot-chip:disabled { opacity: 0.28; cursor: not-allowed; text-decoration: line-through; }
|
||||||
|
|
||||||
|
/* ─── Day-Picker ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.day-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 9px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.day-chip {
|
||||||
|
appearance: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 74px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font);
|
||||||
|
padding: 10px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
transition: all 0.13s ease;
|
||||||
|
}
|
||||||
|
.day-chip:hover { border-color: var(--border); color: var(--text); }
|
||||||
|
.day-chip.selected {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: rgba(125, 176, 255, 0.55);
|
||||||
|
background: linear-gradient(150deg, rgba(125, 176, 255, 0.16), rgba(183, 140, 255, 0.10));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.day-chip .wd { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||||
|
.day-chip .dom { font-size: 20px; font-weight: 700; color: inherit; }
|
||||||
|
.day-chip .mon { font-size: 11px; }
|
||||||
|
|
||||||
|
/* ─── Verschiedenes ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.divider { height: 1px; background: var(--border-soft); border: none; margin: 4px 0; }
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: linear-gradient(100deg, rgba(255,255,255,0.04) 40%, rgba(255,255,255,0.09) 50%, rgba(255,255,255,0.04) 60%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||||
|
|
||||||
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
|
||||||
|
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(6px); }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.page { padding: 16px 14px 64px; }
|
||||||
|
}
|
||||||
309
frontend/src/views/BookingView.vue
Normal file
309
frontend/src/views/BookingView.vue
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ApiError, api } from '../lib/api'
|
||||||
|
import type { PublicInfo } from '../lib/types'
|
||||||
|
import { useToast } from '../stores/toast'
|
||||||
|
import { formatTime, partsInTz, zonedToUtc } from '../lib/tz'
|
||||||
|
import LogoMark from '../components/LogoMark.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const toast = useToast()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
|
||||||
|
const info = ref<PublicInfo | null>(null)
|
||||||
|
const notFound = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
|
||||||
|
interface Day {
|
||||||
|
y: number
|
||||||
|
m: number
|
||||||
|
d: number
|
||||||
|
weekday: number
|
||||||
|
key: string
|
||||||
|
weekdayLabel: string
|
||||||
|
monthLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedDay = ref<number>(0)
|
||||||
|
const duration = ref(30)
|
||||||
|
const selectedSlot = ref<number | null>(null)
|
||||||
|
const form = ref({ name: '', email: '', message: '' })
|
||||||
|
const sending = ref(false)
|
||||||
|
const done = ref(false)
|
||||||
|
|
||||||
|
const days = computed<Day[]>(() => {
|
||||||
|
if (!info.value) return []
|
||||||
|
const tz = info.value.timezone
|
||||||
|
const out: Day[] = []
|
||||||
|
const now = new Date()
|
||||||
|
const today = partsInTz(now, tz)
|
||||||
|
for (let i = 0; i < info.value.horizon_days && out.length < 60; i++) {
|
||||||
|
// Datum in der Zielzone um i Tage verschieben: über UTC-Mitternacht laufen
|
||||||
|
const ts = zonedToUtc(today.year, today.month, today.day + i, 12, 0, tz)
|
||||||
|
const p = partsInTz(new Date(ts), tz)
|
||||||
|
if (!info.value.weekdays.includes(p.weekday)) continue
|
||||||
|
out.push({
|
||||||
|
y: p.year,
|
||||||
|
m: p.month,
|
||||||
|
d: p.day,
|
||||||
|
weekday: p.weekday,
|
||||||
|
key: `${p.year}-${p.month}-${p.day}`,
|
||||||
|
weekdayLabel: new Intl.DateTimeFormat('de-DE', { weekday: 'short' }).format(new Date(ts)),
|
||||||
|
monthLabel: new Intl.DateTimeFormat('de-DE', { month: 'short' }).format(new Date(ts)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const busyMs = computed<[number, number][]>(() =>
|
||||||
|
(info.value?.busy ?? []).map((b) => [new Date(b.start).getTime(), new Date(b.end).getTime()]),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface Slot {
|
||||||
|
ts: number
|
||||||
|
label: string
|
||||||
|
free: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEAD_MS = 10 * 60 * 1000 // min. 10 Minuten Vorlauf
|
||||||
|
|
||||||
|
const slots = computed<Slot[]>(() => {
|
||||||
|
const i = info.value
|
||||||
|
const day = days.value[selectedDay.value]
|
||||||
|
if (!i || !day) return []
|
||||||
|
const tz = i.timezone
|
||||||
|
const out: Slot[] = []
|
||||||
|
for (let m = i.day_start_min; m + duration.value <= i.day_end_min; m += i.slot_minutes) {
|
||||||
|
const ts = zonedToUtc(day.y, day.m, day.d, Math.floor(m / 60), m % 60, tz)
|
||||||
|
const end = ts + duration.value * 60_000
|
||||||
|
const free =
|
||||||
|
ts > Date.now() + LEAD_MS && !busyMs.value.some(([bStart, bEnd]) => ts < bEnd && bStart < end)
|
||||||
|
out.push({ ts, label: formatTime(ts, tz), free })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedDayLabel = computed(() => {
|
||||||
|
const day = days.value[selectedDay.value]
|
||||||
|
if (!day) return ''
|
||||||
|
const date = new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: 'numeric', month: 'long' }).format(
|
||||||
|
new Date(day.y, day.m - 1, day.d),
|
||||||
|
)
|
||||||
|
return date
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(duration, () => (selectedSlot.value = null))
|
||||||
|
watch(selectedDay, () => (selectedSlot.value = null))
|
||||||
|
|
||||||
|
const selectedSlotLabel = computed(() => {
|
||||||
|
if (selectedSlot.value == null || !info.value) return ''
|
||||||
|
const tz = info.value.timezone
|
||||||
|
return `${formatTime(selectedSlot.value, tz)} – ${formatTime(selectedSlot.value + duration.value * 60_000, tz)} Uhr`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (selectedSlot.value == null) return
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
await api.createBooking(slug, {
|
||||||
|
start: new Date(selectedSlot.value).toISOString().replace('.000', ''),
|
||||||
|
duration_minutes: duration.value,
|
||||||
|
name: form.value.name,
|
||||||
|
email: form.value.email,
|
||||||
|
message: form.value.message || undefined,
|
||||||
|
})
|
||||||
|
done.value = true
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Senden fehlgeschlagen')
|
||||||
|
if (e instanceof ApiError && e.status === 409) {
|
||||||
|
const res = await api.publicInfo(slug).catch(() => null)
|
||||||
|
if (res) info.value = res
|
||||||
|
selectedSlot.value = null
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
info.value = await api.publicInfo(slug)
|
||||||
|
if (info.value.durations.length > 0) {
|
||||||
|
duration.value = info.value.durations[Math.floor(info.value.durations.length / 2)]
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 404) notFound.value = true
|
||||||
|
else loadError.value = e instanceof ApiError ? e.message : 'Laden fehlgeschlagen'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="page booking">
|
||||||
|
<!-- Fehlerzustände -->
|
||||||
|
<div v-if="notFound" class="glass state">
|
||||||
|
<LogoMark />
|
||||||
|
<h1>Seite nicht gefunden</h1>
|
||||||
|
<p class="muted">Dieser Buchungslink existiert nicht (mehr).</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="loadError" class="glass state">
|
||||||
|
<h1>Hoppla</h1>
|
||||||
|
<p class="muted">{{ loadError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erfolg -->
|
||||||
|
<div v-else-if="done" class="glass state">
|
||||||
|
<div class="big-check">✓</div>
|
||||||
|
<h1>Anfrage gesendet!</h1>
|
||||||
|
<p class="muted">
|
||||||
|
{{ info?.name }} erhält deine Anfrage für
|
||||||
|
<strong>{{ selectedDayLabel }}</strong> um <strong>{{ selectedSlotLabel }}</strong>
|
||||||
|
und kann sie bestätigen.
|
||||||
|
</p>
|
||||||
|
<p class="faint small">Du hörst dann per E-Mail oder persönlich von {{ info?.name }}.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Laden -->
|
||||||
|
<div v-else-if="!info" class="glass state">
|
||||||
|
<div class="skeleton" style="width: 260px; height: 40px"></div>
|
||||||
|
<div class="skeleton" style="width: 320px; height: 120px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buchung -->
|
||||||
|
<template v-else>
|
||||||
|
<header class="glass head">
|
||||||
|
<div class="row">
|
||||||
|
<LogoMark />
|
||||||
|
<div>
|
||||||
|
<h1>Buchung bei {{ info.name }}</h1>
|
||||||
|
<p class="muted small" style="margin: 2px 0 0">
|
||||||
|
Wähle einen freien Zeitraum. Siehst nur <em>frei</em> oder <em>belegt</em> –
|
||||||
|
niemals die konkreten Termine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="legend small">
|
||||||
|
<span class="dot ok"></span> frei & buchbar
|
||||||
|
<span class="dot busy"></span> belegt
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="glass block">
|
||||||
|
<h2>1 · Tag wählen</h2>
|
||||||
|
<div class="day-strip">
|
||||||
|
<button
|
||||||
|
v-for="(d, i) in days"
|
||||||
|
:key="d.key"
|
||||||
|
class="day-chip"
|
||||||
|
:class="{ selected: selectedDay === i }"
|
||||||
|
@click="selectedDay = i"
|
||||||
|
>
|
||||||
|
<span class="wd">{{ d.weekdayLabel }}</span>
|
||||||
|
<span class="dom">{{ d.d }}</span>
|
||||||
|
<span class="mon">{{ d.monthLabel }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="glass block">
|
||||||
|
<div class="row block-head">
|
||||||
|
<h2 class="grow">2 · Freien Zeitraum wählen</h2>
|
||||||
|
<label class="row small" style="gap: 8px">
|
||||||
|
<span class="muted">Dauer</span>
|
||||||
|
<select v-model.number="duration" class="input" style="width: auto">
|
||||||
|
<option v-for="d in info.durations" :key="d" :value="d">{{ d }} Min.</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="muted small" style="margin: -6px 0 0">{{ selectedDayLabel }} · Zeitzone {{ info.timezone }}</p>
|
||||||
|
|
||||||
|
<div v-if="slots.length === 0" class="muted small">An diesem Tag sind keine Zeiten buchbar.</div>
|
||||||
|
<div v-else-if="slots.every((s) => !s.free)" class="muted small">
|
||||||
|
Für {{ duration }} Min. ist an diesem Tag leider nichts mehr frei – anderer Tag oder andere Dauer?
|
||||||
|
</div>
|
||||||
|
<div v-else class="slot-grid">
|
||||||
|
<button
|
||||||
|
v-for="s in slots"
|
||||||
|
:key="s.ts"
|
||||||
|
class="slot-chip"
|
||||||
|
:class="{ selected: selectedSlot === s.ts }"
|
||||||
|
:disabled="!s.free"
|
||||||
|
@click="selectedSlot = s.ts"
|
||||||
|
>
|
||||||
|
{{ s.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="glass block">
|
||||||
|
<h2>3 · Anfrage senden</h2>
|
||||||
|
<p v-if="selectedSlot != null" class="chosen">
|
||||||
|
🗓 {{ selectedDayLabel }} · <strong>{{ selectedSlotLabel }}</strong> ({{ duration }} Min.)
|
||||||
|
</p>
|
||||||
|
<p v-else class="muted small" style="margin: 0">Zuerst oben einen freien Zeitraum auswählen.</p>
|
||||||
|
|
||||||
|
<form v-if="selectedSlot != null" class="form" @submit.prevent="submit">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>Dein Name</span>
|
||||||
|
<input v-model="form.name" required maxlength="100" class="input" placeholder="Erika Musterfrau" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Deine E-Mail</span>
|
||||||
|
<input v-model="form.email" type="email" required maxlength="254" class="input" placeholder="du@beispiel.de" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>Nachricht (optional)</span>
|
||||||
|
<textarea v-model="form.message" maxlength="2000" class="input" placeholder="Worum geht's?"></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary" type="submit" :disabled="sending">
|
||||||
|
{{ sending ? 'Senden…' : 'Buchungsanfrage senden' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="row center faint small" style="justify-content: center">
|
||||||
|
<LogoMark /> WannPassts
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.booking { display: flex; flex-direction: column; gap: 16px; max-width: 780px; }
|
||||||
|
.state {
|
||||||
|
margin-top: 18vh;
|
||||||
|
padding: 48px 36px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.state h1 { margin: 0; font-size: 26px; }
|
||||||
|
.big-check {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 34px;
|
||||||
|
color: #071018;
|
||||||
|
background: linear-gradient(135deg, var(--ok), var(--accent-3));
|
||||||
|
box-shadow: 0 14px 44px rgba(70, 224, 165, 0.4);
|
||||||
|
}
|
||||||
|
.head { padding: 22px 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.head h1 { margin: 0; font-size: 22px; }
|
||||||
|
.legend { display: flex; align-items: center; gap: 8px; color: var(--text-muted); }
|
||||||
|
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
|
||||||
|
.dot.ok { background: var(--ok); box-shadow: 0 0 10px rgba(70, 224, 165, 0.7); }
|
||||||
|
.dot.busy { background: var(--danger); box-shadow: 0 0 10px rgba(255, 122, 138, 0.6); }
|
||||||
|
.block { padding: 20px 24px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.block h2 { margin: 0; font-size: 15px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); }
|
||||||
|
.block-head { align-items: baseline; }
|
||||||
|
.chosen { margin: 0; font-size: 15px; }
|
||||||
|
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||||
|
</style>
|
||||||
149
frontend/src/views/DashboardView.vue
Normal file
149
frontend/src/views/DashboardView.vue
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuth } from '../stores/auth'
|
||||||
|
import { useToast } from '../stores/toast'
|
||||||
|
import LogoMark from '../components/LogoMark.vue'
|
||||||
|
import ShareLinkCard from '../components/ShareLinkCard.vue'
|
||||||
|
import SectionConnections from '../components/SectionConnections.vue'
|
||||||
|
import SectionRequests from '../components/SectionRequests.vue'
|
||||||
|
import SectionSettings from '../components/SectionSettings.vue'
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const tab = ref<'overview' | 'requests' | 'calendars' | 'settings'>('overview')
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ value: 'overview', label: 'Übersicht' },
|
||||||
|
{ value: 'requests', label: 'Anfragen' },
|
||||||
|
{ value: 'calendars', label: 'Kalender' },
|
||||||
|
{ value: 'settings', label: 'Einstellungen' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await auth.init()
|
||||||
|
if (!auth.user) {
|
||||||
|
auth.logout()
|
||||||
|
router.push({ name: 'login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
|
||||||
|
if (route.query.google === 'ok') {
|
||||||
|
toast.ok('Google Kalender verbunden 🎉')
|
||||||
|
tab.value = 'calendars'
|
||||||
|
} else if (route.query.google === 'error') {
|
||||||
|
toast.error('Google-Verbindung fehlgeschlagen: ' + (route.query.reason || 'unbekannter Fehler'))
|
||||||
|
tab.value = 'calendars'
|
||||||
|
} else if (typeof route.query.tab === 'string') {
|
||||||
|
tab.value = route.query.tab as typeof tab.value
|
||||||
|
}
|
||||||
|
if (Object.keys(route.query).length > 0) {
|
||||||
|
router.replace({ query: {} })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
auth.logout()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="page dash" v-if="!loading && auth.user">
|
||||||
|
<header class="glass dash-header">
|
||||||
|
<div class="row">
|
||||||
|
<LogoMark />
|
||||||
|
<strong class="brand">WannPassts</strong>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="muted small hide-sm">{{ auth.user.email }}</span>
|
||||||
|
<button class="btn btn-sm" @click="logout">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs">
|
||||||
|
<button
|
||||||
|
v-for="t in tabs"
|
||||||
|
:key="t.value"
|
||||||
|
class="tab"
|
||||||
|
:class="{ active: tab === t.value }"
|
||||||
|
@click="tab = t.value"
|
||||||
|
>
|
||||||
|
{{ t.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div v-if="tab === 'overview'" class="col overview">
|
||||||
|
<ShareLinkCard />
|
||||||
|
<div class="overview-grid">
|
||||||
|
<router-link :to="{ query: { tab: 'calendars' } }" class="glass tile" @click="tab = 'calendars'">
|
||||||
|
<span class="tile-icon">📆</span>
|
||||||
|
<strong>Kalender verbinden</strong>
|
||||||
|
<span class="muted small">Google, iCloud/CalDAV oder ICS-Link hinzufügen</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link :to="{ query: { tab: 'requests' } }" class="glass tile" @click="tab = 'requests'">
|
||||||
|
<span class="tile-icon">📨</span>
|
||||||
|
<strong>Anfragen prüfen</strong>
|
||||||
|
<span class="muted small">Buchungsanfragen annehmen oder ablehnen</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link :to="{ query: { tab: 'settings' } }" class="glass tile" @click="tab = 'settings'">
|
||||||
|
<span class="tile-icon">⚙️</span>
|
||||||
|
<strong>Buchungsregeln</strong>
|
||||||
|
<span class="muted small">Zeitfenster, Dauern, Slot-Größe festlegen</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<section class="glass privacy">
|
||||||
|
<strong>🔒 Datenschutz by Design</strong>
|
||||||
|
<p class="muted small" style="margin: 6px 0 0">
|
||||||
|
Besucher deiner Buchungsseite sehen ausschließlich <em>frei</em> oder <em>belegt</em> –
|
||||||
|
niemals Titel, Ort oder Beschreibung deiner Termine. Google-Anbindungen nutzen die
|
||||||
|
FreeBusy-API, die strukturell keine Termindetails liefert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionRequests v-else-if="tab === 'requests'" />
|
||||||
|
<SectionConnections v-else-if="tab === 'calendars'" />
|
||||||
|
<SectionSettings v-else />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main v-else class="page" style="display: flex; justify-content: center; padding-top: 20vh">
|
||||||
|
<div class="skeleton" style="width: 320px; height: 120px"></div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dash { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.dash-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 20px;
|
||||||
|
}
|
||||||
|
.brand { font-size: 17px; letter-spacing: -0.01em; }
|
||||||
|
.overview { gap: 18px; }
|
||||||
|
.overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 22px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tile:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.24); }
|
||||||
|
.tile-icon { font-size: 26px; }
|
||||||
|
.privacy { padding: 20px 24px; }
|
||||||
|
@media (max-width: 520px) { .hide-sm { display: none; } }
|
||||||
|
</style>
|
||||||
102
frontend/src/views/LandingView.vue
Normal file
102
frontend/src/views/LandingView.vue
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import LogoMark from '../components/LogoMark.vue'
|
||||||
|
import GlassCard from '../components/GlassCard.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="page landing">
|
||||||
|
<section class="hero glass">
|
||||||
|
<div class="hero-glow" aria-hidden="true"></div>
|
||||||
|
<LogoMark />
|
||||||
|
<h1>Wann<span class="grad">Passts</span></h1>
|
||||||
|
<p class="tagline">
|
||||||
|
Deine freien Zeiten – geteilt per Link.<br />
|
||||||
|
Verbinde deinen Kalender, lass andere buchen und gib dabei <strong>keine Termindetails</strong> preis.
|
||||||
|
</p>
|
||||||
|
<div class="row center">
|
||||||
|
<router-link to="/register" class="btn btn-primary">Kostenlos starten</router-link>
|
||||||
|
<router-link to="/login" class="btn">Anmelden</router-link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="features">
|
||||||
|
<GlassCard title="🔐 Kalender verbinden">
|
||||||
|
<p class="muted small">
|
||||||
|
Google Kalender, iCloud (CalDAV) oder jeder ICS-Link. WannPassts liest ausschließlich
|
||||||
|
<em>beschäftigt / frei</em> – Titel, Ort und Notizen bleiben auf deinem Gerät bzw. beim Anbieter.
|
||||||
|
</p>
|
||||||
|
</GlassCard>
|
||||||
|
<GlassCard title="🔗 Link teilen">
|
||||||
|
<p class="muted small">
|
||||||
|
Deine persönliche Buchungsseite zeigt nur freie und belegte Zeitfenster. Freunde, Kunden
|
||||||
|
und Kollegen wählen einen freien Slot und stellen eine Anfrage.
|
||||||
|
</p>
|
||||||
|
</GlassCard>
|
||||||
|
<GlassCard title="✅ Anfragen entscheiden">
|
||||||
|
<p class="muted small">
|
||||||
|
Anfragen landen in deinem Dashboard: annehmen oder ablehnen. Angenommene Zeiten werden
|
||||||
|
automatisch als belegt markiert – Doppelbuchungen werden verhindert.
|
||||||
|
</p>
|
||||||
|
</GlassCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="faint small foot">Liquid Glass · Vue 3 + Go · Deine Termine bleiben deine Termine.</p>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.landing {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-top: 6vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 56px 32px 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.hero-glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: -40%;
|
||||||
|
background: radial-gradient(600px 300px at 50% 0%, rgba(125, 176, 255, 0.16), transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: clamp(40px, 7vw, 64px);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.grad {
|
||||||
|
background: linear-gradient(120deg, var(--accent), var(--accent-2));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
.tagline {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 460px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.row.center { justify-content: center; }
|
||||||
|
|
||||||
|
.features {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.foot { text-align: center; }
|
||||||
|
|
||||||
|
a.btn { text-decoration: none; display: inline-block; }
|
||||||
|
</style>
|
||||||
82
frontend/src/views/LoginView.vue
Normal file
82
frontend/src/views/LoginView.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { useAuth } from '../stores/auth'
|
||||||
|
import { useToast } from '../stores/toast'
|
||||||
|
import LogoMark from '../components/LogoMark.vue'
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await auth.login(email.value, password.value)
|
||||||
|
toast.ok('Willkommen zurück!')
|
||||||
|
router.push((route.query.redirect as string) || '/app')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Anmeldung fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="page auth">
|
||||||
|
<form class="glass card" @submit.prevent="submit">
|
||||||
|
<div class="row" style="justify-content: center">
|
||||||
|
<LogoMark />
|
||||||
|
</div>
|
||||||
|
<h1>Willkommen zurück</h1>
|
||||||
|
<p class="muted small center">Melde dich an, um deine Buchungsseite zu verwalten.</p>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>E-Mail</span>
|
||||||
|
<input v-model="email" type="email" required autocomplete="email" class="input" placeholder="du@beispiel.de" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Passwort</span>
|
||||||
|
<input v-model="password" type="password" required autocomplete="current-password" class="input" placeholder="••••••••" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" type="submit" :disabled="busy || !email || !password">
|
||||||
|
{{ busy ? 'Anmelden…' : 'Anmelden' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="muted small center">
|
||||||
|
Noch kein Konto?
|
||||||
|
<router-link to="/register">Jetzt registrieren</router-link>
|
||||||
|
</p>
|
||||||
|
<p class="small center"><router-link to="/">← Zur Startseite</router-link></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
padding: 34px 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.card p { margin: 0; }
|
||||||
|
h1 { margin: 4px 0 0; font-size: 24px; text-align: center; }
|
||||||
|
.center { text-align: center; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
90
frontend/src/views/RegisterView.vue
Normal file
90
frontend/src/views/RegisterView.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { useAuth } from '../stores/auth'
|
||||||
|
import { useToast } from '../stores/toast'
|
||||||
|
import { guessTimezone } from '../lib/tz'
|
||||||
|
import LogoMark from '../components/LogoMark.vue'
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (password.value.length < 8) {
|
||||||
|
toast.error('Das Passwort braucht mindestens 8 Zeichen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await auth.register(email.value, password.value, name.value, guessTimezone())
|
||||||
|
toast.ok('Konto erstellt – willkommen bei WannPassts!')
|
||||||
|
router.push('/app')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Registrierung fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="page auth">
|
||||||
|
<form class="glass card" @submit.prevent="submit">
|
||||||
|
<div class="row" style="justify-content: center">
|
||||||
|
<LogoMark />
|
||||||
|
</div>
|
||||||
|
<h1>Konto erstellen</h1>
|
||||||
|
<p class="muted small center">In zwei Minuten buchbar – ganz ohne Termine preiszugeben.</p>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Name (sehen Besucher deiner Buchungsseite)</span>
|
||||||
|
<input v-model="name" required maxlength="80" class="input" placeholder="Max Mustermann" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>E-Mail</span>
|
||||||
|
<input v-model="email" type="email" required autocomplete="email" class="input" placeholder="du@beispiel.de" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Passwort (min. 8 Zeichen)</span>
|
||||||
|
<input v-model="password" type="password" required autocomplete="new-password" class="input" placeholder="••••••••" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" type="submit" :disabled="busy || !name || !email || !password">
|
||||||
|
{{ busy ? 'Wird erstellt…' : 'Konto erstellen' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="muted small center">
|
||||||
|
Schon dabei? <router-link to="/login">Anmelden</router-link>
|
||||||
|
</p>
|
||||||
|
<p class="small center"><router-link to="/">← Zur Startseite</router-link></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
padding: 34px 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.card p { margin: 0; }
|
||||||
|
h1 { margin: 4px 0 0; font-size: 24px; text-align: center; }
|
||||||
|
.center { text-align: center; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue