Google Calendar OAuth2 Integration + CalDAV-Kalender ausblenden statt löschen
- Google OAuth2 Flow: Admin konfiguriert Client-ID/Secret, User verbindet per Klick - Google Calendar API v3: Events lesen, erstellen, bearbeiten, löschen - GoogleAccount Model + google_router mit Token-Refresh - Google-Events in Event-Pipeline integriert - Frontend: Google Kalender in Sidebar, Dropdown, Event-CRUD-Routing - CalDAV-Kalender: Ausblenden statt ganzes Konto löschen, Einblenden in Einstellungen - Ausgeblendete Kalender Sektion in Einstellungen
This commit is contained in:
@@ -12,7 +12,7 @@ from sqlalchemy import text
|
|||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from database import Base, engine
|
from database import Base, engine
|
||||||
from routers import auth_router, caldav_router, ical_router, local_router, profile_router, settings_router, users_router
|
from routers import auth_router, caldav_router, google_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ app.include_router(settings_router.router, prefix="/api/settings", tags=["settin
|
|||||||
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
|
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
|
||||||
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
|
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
|
||||||
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||||
|
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||||
|
|
||||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class User(Base):
|
|||||||
ical_subscriptions = relationship(
|
ical_subscriptions = relationship(
|
||||||
"ICalSubscription", back_populates="user", cascade="all, delete-orphan"
|
"ICalSubscription", back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
google_accounts = relationship(
|
||||||
|
"GoogleAccount", back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalDAVAccount(Base):
|
class CalDAVAccount(Base):
|
||||||
@@ -138,3 +141,16 @@ class ICalOverride(Base):
|
|||||||
color = Column(String(7), nullable=True)
|
color = Column(String(7), nullable=True)
|
||||||
|
|
||||||
subscription = relationship("ICalSubscription", back_populates="overrides")
|
subscription = relationship("ICalSubscription", back_populates="overrides")
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleAccount(Base):
|
||||||
|
__tablename__ = "google_accounts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
email = Column(String(255), nullable=False)
|
||||||
|
access_token = Column(Text, nullable=False)
|
||||||
|
refresh_token = Column(Text, nullable=False)
|
||||||
|
token_expiry = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="google_accounts")
|
||||||
|
|||||||
@@ -336,6 +336,19 @@ def get_events(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Error fetching iCal subscription %s: %s", sub.id, exc)
|
logger.error("Error fetching iCal subscription %s: %s", sub.id, exc)
|
||||||
|
|
||||||
|
# ── Google Calendar events ───────────────────────────
|
||||||
|
from routers.google_router import get_google_events
|
||||||
|
google_accounts = (
|
||||||
|
db.query(models.GoogleAccount)
|
||||||
|
.filter(models.GoogleAccount.user_id == current_user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for g_acc in google_accounts:
|
||||||
|
try:
|
||||||
|
all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
|
||||||
|
|
||||||
return all_events
|
return all_events
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
363
backend/routers/google_router.py
Normal file
363
backend/routers/google_router.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import requests as http_requests
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import models
|
||||||
|
from auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
|
||||||
|
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")
|
||||||
|
GOOGLE_REDIRECT_URI = os.environ.get("GOOGLE_REDIRECT_URI", "")
|
||||||
|
|
||||||
|
SCOPES = "https://www.googleapis.com/auth/calendar"
|
||||||
|
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
|
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||||
|
CALENDAR_API = "https://www.googleapis.com/calendar/v3"
|
||||||
|
|
||||||
|
|
||||||
|
def _google_configured() -> bool:
|
||||||
|
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_access_token(account: models.GoogleAccount, db: Session) -> str:
|
||||||
|
"""Refresh the access token if expired, return valid access token."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now:
|
||||||
|
return account.access_token
|
||||||
|
|
||||||
|
resp = http_requests.post(TOKEN_URL, data={
|
||||||
|
"client_id": GOOGLE_CLIENT_ID,
|
||||||
|
"client_secret": GOOGLE_CLIENT_SECRET,
|
||||||
|
"refresh_token": account.refresh_token,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
}, timeout=15)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error("Google token refresh failed: %s", resp.text)
|
||||||
|
raise HTTPException(401, "Google-Token abgelaufen, bitte neu verbinden")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
account.access_token = data["access_token"]
|
||||||
|
account.token_expiry = datetime.fromtimestamp(
|
||||||
|
now.timestamp() + data.get("expires_in", 3600), tz=timezone.utc
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return account.access_token
|
||||||
|
|
||||||
|
|
||||||
|
def _google_api(token: str, path: str, method: str = "GET", json_body=None, params=None):
|
||||||
|
"""Make an authenticated request to Google Calendar API."""
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
url = f"{CALENDAR_API}{path}"
|
||||||
|
if method == "GET":
|
||||||
|
resp = http_requests.get(url, headers=headers, params=params, timeout=15)
|
||||||
|
elif method == "POST":
|
||||||
|
resp = http_requests.post(url, headers=headers, json=json_body, timeout=15)
|
||||||
|
elif method == "PUT":
|
||||||
|
resp = http_requests.put(url, headers=headers, json=json_body, timeout=15)
|
||||||
|
elif method == "PATCH":
|
||||||
|
resp = http_requests.patch(url, headers=headers, json=json_body, timeout=15)
|
||||||
|
elif method == "DELETE":
|
||||||
|
resp = http_requests.delete(url, headers=headers, timeout=15)
|
||||||
|
if resp.status_code == 204:
|
||||||
|
return {"ok": True}
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported method: {method}")
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
logger.error("Google API error %s %s: %s", method, path, resp.text)
|
||||||
|
raise HTTPException(resp.status_code, f"Google API Fehler: {resp.status_code}")
|
||||||
|
return resp.json() if resp.text else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_event_body(data: dict) -> dict:
|
||||||
|
"""Convert our event format to Google Calendar API format."""
|
||||||
|
body = {"summary": data.get("title", "")}
|
||||||
|
if data.get("location"):
|
||||||
|
body["location"] = data["location"]
|
||||||
|
if data.get("description"):
|
||||||
|
body["description"] = data["description"]
|
||||||
|
|
||||||
|
if data.get("allDay"):
|
||||||
|
body["start"] = {"date": data["start"][:10]}
|
||||||
|
body["end"] = {"date": data["end"][:10]}
|
||||||
|
else:
|
||||||
|
body["start"] = {"dateTime": data["start"]}
|
||||||
|
body["end"] = {"dateTime": data["end"]}
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_google_event(ev: dict, account_id: int, cal_name: str, cal_color: str) -> dict:
|
||||||
|
"""Convert Google Calendar event to our format."""
|
||||||
|
start = ev.get("start", {})
|
||||||
|
end = ev.get("end", {})
|
||||||
|
all_day = "date" in start
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": ev["id"],
|
||||||
|
"url": f"google://{account_id}/{ev['id']}",
|
||||||
|
"title": ev.get("summary", "(Kein Titel)"),
|
||||||
|
"start": start.get("date") or start.get("dateTime", ""),
|
||||||
|
"end": end.get("date") or end.get("dateTime", ""),
|
||||||
|
"allDay": all_day,
|
||||||
|
"location": ev.get("location", ""),
|
||||||
|
"description": ev.get("description", ""),
|
||||||
|
"color": None,
|
||||||
|
"calendar_id": f"google-{account_id}",
|
||||||
|
"calendar_name": cal_name,
|
||||||
|
"calendarColor": cal_color,
|
||||||
|
"source": "google",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── OAuth2 Flow ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/configured")
|
||||||
|
def is_configured():
|
||||||
|
return {"configured": _google_configured()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth-url")
|
||||||
|
def get_auth_url(current_user: models.User = Depends(get_current_user)):
|
||||||
|
if not _google_configured():
|
||||||
|
raise HTTPException(400, "Google OAuth nicht konfiguriert")
|
||||||
|
|
||||||
|
redirect_uri = GOOGLE_REDIRECT_URI or ""
|
||||||
|
params = {
|
||||||
|
"client_id": GOOGLE_CLIENT_ID,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": SCOPES,
|
||||||
|
"access_type": "offline",
|
||||||
|
"prompt": "consent",
|
||||||
|
"state": str(current_user.id),
|
||||||
|
}
|
||||||
|
return {"url": f"{AUTH_URL}?{urlencode(params)}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
def oauth_callback(
|
||||||
|
code: str = Query(...),
|
||||||
|
state: str = Query(""),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not _google_configured():
|
||||||
|
raise HTTPException(400, "Google OAuth nicht konfiguriert")
|
||||||
|
|
||||||
|
# Exchange code for tokens
|
||||||
|
resp = http_requests.post(TOKEN_URL, data={
|
||||||
|
"client_id": GOOGLE_CLIENT_ID,
|
||||||
|
"client_secret": GOOGLE_CLIENT_SECRET,
|
||||||
|
"code": code,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"redirect_uri": GOOGLE_REDIRECT_URI,
|
||||||
|
}, timeout=15)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error("Google token exchange failed: %s", resp.text)
|
||||||
|
raise HTTPException(400, "Fehler beim Google-Login")
|
||||||
|
|
||||||
|
tokens = resp.json()
|
||||||
|
access_token = tokens["access_token"]
|
||||||
|
refresh_token = tokens.get("refresh_token", "")
|
||||||
|
expires_in = tokens.get("expires_in", 3600)
|
||||||
|
|
||||||
|
# Get user email
|
||||||
|
user_info = http_requests.get(
|
||||||
|
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
timeout=10,
|
||||||
|
).json()
|
||||||
|
email = user_info.get("email", "Unbekannt")
|
||||||
|
|
||||||
|
user_id = int(state) if state.isdigit() else None
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(400, "Ungültiger State-Parameter")
|
||||||
|
|
||||||
|
# Check if account already exists
|
||||||
|
existing = (
|
||||||
|
db.query(models.GoogleAccount)
|
||||||
|
.filter(models.GoogleAccount.user_id == user_id, models.GoogleAccount.email == email)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
existing.access_token = access_token
|
||||||
|
if refresh_token:
|
||||||
|
existing.refresh_token = refresh_token
|
||||||
|
existing.token_expiry = datetime.fromtimestamp(
|
||||||
|
datetime.now(timezone.utc).timestamp() + expires_in, tz=timezone.utc
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
db.add(models.GoogleAccount(
|
||||||
|
user_id=user_id,
|
||||||
|
email=email,
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
token_expiry=datetime.fromtimestamp(
|
||||||
|
datetime.now(timezone.utc).timestamp() + expires_in, tz=timezone.utc
|
||||||
|
),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Redirect back to app
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Account Management ───────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/accounts")
|
||||||
|
def list_accounts(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
accounts = (
|
||||||
|
db.query(models.GoogleAccount)
|
||||||
|
.filter(models.GoogleAccount.user_id == current_user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [{"id": a.id, "email": a.email} for a in accounts]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/accounts/{account_id}")
|
||||||
|
def delete_account(
|
||||||
|
account_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
acc = (
|
||||||
|
db.query(models.GoogleAccount)
|
||||||
|
.filter(models.GoogleAccount.id == account_id, models.GoogleAccount.user_id == current_user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not acc:
|
||||||
|
raise HTTPException(404, "Account not found")
|
||||||
|
db.delete(acc)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Events ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
|
||||||
|
"""Fetch events from all calendars in a Google account."""
|
||||||
|
try:
|
||||||
|
token = _refresh_access_token(account, db)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_events = []
|
||||||
|
try:
|
||||||
|
cal_list = _google_api(token, "/users/me/calendarList")
|
||||||
|
for cal in cal_list.get("items", []):
|
||||||
|
if cal.get("deleted"):
|
||||||
|
continue
|
||||||
|
cal_id = cal["id"]
|
||||||
|
cal_name = cal.get("summary", cal_id)
|
||||||
|
cal_color = cal.get("backgroundColor", "#4285f4")
|
||||||
|
|
||||||
|
events_resp = _google_api(token, f"/calendars/{cal_id}/events", params={
|
||||||
|
"timeMin": start_dt.isoformat(),
|
||||||
|
"timeMax": end_dt.isoformat(),
|
||||||
|
"singleEvents": "true",
|
||||||
|
"maxResults": 500,
|
||||||
|
})
|
||||||
|
for ev in events_resp.get("items", []):
|
||||||
|
if ev.get("status") == "cancelled":
|
||||||
|
continue
|
||||||
|
all_events.append(_parse_google_event(ev, account.id, cal_name, cal_color))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error fetching Google Calendar for %s: %s", account.email, exc)
|
||||||
|
|
||||||
|
return all_events
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleEventCreate(BaseModel):
|
||||||
|
account_id: int
|
||||||
|
title: str
|
||||||
|
start: str
|
||||||
|
end: str
|
||||||
|
allDay: bool = False
|
||||||
|
location: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleEventUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
start: Optional[str] = None
|
||||||
|
end: Optional[str] = None
|
||||||
|
allDay: Optional[bool] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events")
|
||||||
|
def create_event(
|
||||||
|
data: GoogleEventCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
acc = (
|
||||||
|
db.query(models.GoogleAccount)
|
||||||
|
.filter(models.GoogleAccount.id == data.account_id, models.GoogleAccount.user_id == current_user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not acc:
|
||||||
|
raise HTTPException(404, "Google account not found")
|
||||||
|
|
||||||
|
token = _refresh_access_token(acc, db)
|
||||||
|
body = _build_event_body(data.model_dump())
|
||||||
|
result = _google_api(token, "/calendars/primary/events", method="POST", json_body=body)
|
||||||
|
return {"id": result.get("id"), "ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/events/{account_id}/{event_id}")
|
||||||
|
def update_event(
|
||||||
|
account_id: int,
|
||||||
|
event_id: str,
|
||||||
|
data: GoogleEventUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
acc = (
|
||||||
|
db.query(models.GoogleAccount)
|
||||||
|
.filter(models.GoogleAccount.id == account_id, models.GoogleAccount.user_id == current_user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not acc:
|
||||||
|
raise HTTPException(404, "Google account not found")
|
||||||
|
|
||||||
|
token = _refresh_access_token(acc, db)
|
||||||
|
body = _build_event_body(data.model_dump(exclude_none=True))
|
||||||
|
_google_api(token, f"/calendars/primary/events/{event_id}", method="PATCH", json_body=body)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/events/{account_id}/{event_id}")
|
||||||
|
def delete_event(
|
||||||
|
account_id: int,
|
||||||
|
event_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
acc = (
|
||||||
|
db.query(models.GoogleAccount)
|
||||||
|
.filter(models.GoogleAccount.id == account_id, models.GoogleAccount.user_id == current_user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not acc:
|
||||||
|
raise HTTPException(404, "Google account not found")
|
||||||
|
|
||||||
|
token = _refresh_access_token(acc, db)
|
||||||
|
_google_api(token, f"/calendars/primary/events/{event_id}", method="DELETE")
|
||||||
|
return {"ok": True}
|
||||||
@@ -159,6 +159,7 @@
|
|||||||
<button data-action="local">Lokaler Kalender</button>
|
<button data-action="local">Lokaler Kalender</button>
|
||||||
<button data-action="caldav">CalDAV-Konto</button>
|
<button data-action="caldav">CalDAV-Konto</button>
|
||||||
<button data-action="ical">iCal-URL abonnieren</button>
|
<button data-action="ical">iCal-URL abonnieren</button>
|
||||||
|
<button data-action="google">Google Kalender</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,6 +428,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section" id="settings-hidden-cals-section">
|
||||||
|
<h4>Ausgeblendete Kalender</h4>
|
||||||
|
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)">Keine ausgeblendeten Kalender</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-section" id="settings-users-section">
|
<div class="settings-section" id="settings-users-section">
|
||||||
<h4>Benutzerverwaltung <span class="badge-admin">Admin</span></h4>
|
<h4>Benutzerverwaltung <span class="badge-admin">Admin</span></h4>
|
||||||
<div id="users-list"></div>
|
<div id="users-list"></div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ let state = {
|
|||||||
accounts: [],
|
accounts: [],
|
||||||
localCalendars: [],
|
localCalendars: [],
|
||||||
icalSubscriptions: [],
|
icalSubscriptions: [],
|
||||||
|
googleAccounts: [],
|
||||||
settings: {},
|
settings: {},
|
||||||
dimPast: false,
|
dimPast: false,
|
||||||
editingEvent: null, // null = new event
|
editingEvent: null, // null = new event
|
||||||
@@ -37,17 +38,19 @@ let state = {
|
|||||||
|
|
||||||
// ── Public init ───────────────────────────────────────────
|
// ── Public init ───────────────────────────────────────────
|
||||||
export async function initCalendar() {
|
export async function initCalendar() {
|
||||||
const [settings, accounts, localCalendars, icalSubscriptions] = await Promise.all([
|
const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts] = await Promise.all([
|
||||||
api.get('/settings/'),
|
api.get('/settings/'),
|
||||||
api.get('/caldav/accounts'),
|
api.get('/caldav/accounts'),
|
||||||
api.get('/local/calendars'),
|
api.get('/local/calendars').catch(() => []),
|
||||||
api.get('/ical/subscriptions'),
|
api.get('/ical/subscriptions').catch(() => []),
|
||||||
|
api.get('/google/accounts').catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
state.settings = settings;
|
state.settings = settings;
|
||||||
state.accounts = accounts;
|
state.accounts = accounts;
|
||||||
state.localCalendars = localCalendars;
|
state.localCalendars = localCalendars;
|
||||||
state.icalSubscriptions = icalSubscriptions;
|
state.icalSubscriptions = icalSubscriptions;
|
||||||
|
state.googleAccounts = googleAccounts;
|
||||||
state.currentView = settings.default_view || 'month';
|
state.currentView = settings.default_view || 'month';
|
||||||
state.dimPast = settings.dim_past_events;
|
state.dimPast = settings.dim_past_events;
|
||||||
weekStartDay = settings.week_start_day || 'monday';
|
weekStartDay = settings.week_start_day || 'monday';
|
||||||
@@ -258,19 +261,21 @@ function renderCalendarList() {
|
|||||||
|
|
||||||
// ── CalDAV accounts ────────────────────────────────────
|
// ── CalDAV accounts ────────────────────────────────────
|
||||||
if (state.accounts.length) {
|
if (state.accounts.length) {
|
||||||
html += state.accounts.map(acc =>
|
html += state.accounts.map(acc => {
|
||||||
`<div class="cal-account-name">${escHtml(acc.name)}</div>` +
|
const visibleCals = acc.calendars.filter(c => !c._hidden);
|
||||||
acc.calendars.map(cal =>
|
if (!visibleCals.length) return '';
|
||||||
|
return `<div class="cal-account-name">${escHtml(acc.name)}</div>` +
|
||||||
|
visibleCals.map(cal =>
|
||||||
`<div class="cal-item" data-cal-id="${cal.id}" data-source="caldav">
|
`<div class="cal-item" data-cal-id="${cal.id}" data-source="caldav">
|
||||||
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="caldav" />
|
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="caldav" />
|
||||||
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="caldav" title="Farbe ändern"></div>
|
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="caldav" title="Farbe ändern"></div>
|
||||||
<span class="cal-item-name" data-source="caldav">${escHtml(cal.name)}</span>
|
<span class="cal-item-name" data-source="caldav">${escHtml(cal.name)}</span>
|
||||||
<button class="icon-btn mini-btn cal-item-remove" data-acc-id="${acc.id}" data-source="caldav" title="Konto entfernen">
|
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="caldav" title="Kalender ausblenden">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>`
|
</div>`
|
||||||
).join('')
|
|
||||||
).join('');
|
).join('');
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Local calendars ────────────────────────────────────
|
// ── Local calendars ────────────────────────────────────
|
||||||
@@ -303,6 +308,20 @@ function renderCalendarList() {
|
|||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Google accounts ───────────────────────────────────
|
||||||
|
if (state.googleAccounts.length) {
|
||||||
|
html += `<div class="cal-account-name">Google Kalender</div>`;
|
||||||
|
html += state.googleAccounts.map(acc =>
|
||||||
|
`<div class="cal-item" data-acc-id="${acc.id}" data-source="google">
|
||||||
|
<div class="cal-item-dot" style="background:#4285f4"></div>
|
||||||
|
<span class="cal-item-name" data-source="google">${escHtml(acc.email)}</span>
|
||||||
|
<button class="icon-btn mini-btn cal-item-remove" data-acc-id="${acc.id}" data-source="google" title="Google-Konto entfernen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
container.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">Keine Kalender</div>`;
|
container.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">Keine Kalender</div>`;
|
||||||
return;
|
return;
|
||||||
@@ -421,10 +440,13 @@ function renderCalendarList() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const source = btn.dataset.source;
|
const source = btn.dataset.source;
|
||||||
if (source === 'caldav') {
|
if (source === 'caldav') {
|
||||||
if (!confirm('CalDAV-Konto wirklich entfernen?')) return;
|
const calId = parseInt(btn.dataset.calId);
|
||||||
const accId = parseInt(btn.dataset.accId);
|
await api.put(`/caldav/calendars/${calId}`, { enabled: false });
|
||||||
await api.delete(`/caldav/accounts/${accId}`);
|
for (const acc of state.accounts) {
|
||||||
state.accounts = state.accounts.filter(a => a.id !== accId);
|
for (const cal of acc.calendars) {
|
||||||
|
if (cal.id === calId) { cal.enabled = false; cal._hidden = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (source === 'local') {
|
} else if (source === 'local') {
|
||||||
if (!confirm('Lokalen Kalender wirklich löschen?')) return;
|
if (!confirm('Lokalen Kalender wirklich löschen?')) return;
|
||||||
const calId = parseInt(btn.dataset.calId);
|
const calId = parseInt(btn.dataset.calId);
|
||||||
@@ -435,6 +457,11 @@ function renderCalendarList() {
|
|||||||
const subId = parseInt(btn.dataset.subId);
|
const subId = parseInt(btn.dataset.subId);
|
||||||
await api.delete(`/ical/subscriptions/${subId}`);
|
await api.delete(`/ical/subscriptions/${subId}`);
|
||||||
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId);
|
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId);
|
||||||
|
} else if (source === 'google') {
|
||||||
|
if (!confirm('Google-Konto wirklich entfernen?')) return;
|
||||||
|
const accId = parseInt(btn.dataset.accId);
|
||||||
|
await api.delete(`/google/accounts/${accId}`);
|
||||||
|
state.googleAccounts = state.googleAccounts.filter(a => a.id !== accId);
|
||||||
}
|
}
|
||||||
renderCalendarList();
|
renderCalendarList();
|
||||||
fetchAndRender();
|
fetchAndRender();
|
||||||
@@ -513,6 +540,20 @@ function bindSidebar() {
|
|||||||
dropdown.classList.add('hidden');
|
dropdown.classList.add('hidden');
|
||||||
openICalSubModal();
|
openICalSubModal();
|
||||||
};
|
};
|
||||||
|
dropdown.querySelector('[data-action="google"]').onclick = async () => {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
try {
|
||||||
|
const { configured } = await api.get('/google/configured');
|
||||||
|
if (!configured) {
|
||||||
|
showToast('Google OAuth ist nicht konfiguriert (Admin muss GOOGLE_CLIENT_ID/SECRET setzen)', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { url } = await api.get('/google/auth-url');
|
||||||
|
window.location.href = url;
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Fehler: ' + e.message, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Event Popup ───────────────────────────────────────────
|
// ── Event Popup ───────────────────────────────────────────
|
||||||
@@ -558,7 +599,10 @@ function showEventPopup(ev, anchor) {
|
|||||||
if (!confirm(`"${ev.title}" wirklich löschen?`)) return;
|
if (!confirm(`"${ev.title}" wirklich löschen?`)) return;
|
||||||
popup.classList.add('hidden');
|
popup.classList.add('hidden');
|
||||||
try {
|
try {
|
||||||
if (ev.source === 'local') {
|
if (ev.source === 'google') {
|
||||||
|
const accId = ev.calendar_id.replace('google-', '');
|
||||||
|
await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`);
|
||||||
|
} else if (ev.source === 'local') {
|
||||||
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
|
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
|
||||||
} else if (ev.source === 'ical') {
|
} else if (ev.source === 'ical') {
|
||||||
const subId = ev.calendar_id.replace('ical-', '');
|
const subId = ev.calendar_id.replace('ical-', '');
|
||||||
@@ -604,6 +648,14 @@ function populateCalendarSelect(selectedId) {
|
|||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
});
|
});
|
||||||
// iCal subscriptions are read-only, not shown here
|
// iCal subscriptions are read-only, not shown here
|
||||||
|
// Google calendars (read/write)
|
||||||
|
state.googleAccounts.forEach(acc => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = `google-${acc.id}`;
|
||||||
|
opt.textContent = `Google / ${acc.email}`;
|
||||||
|
if (`google-${acc.id}` === selectedId) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNewEventModal(date) {
|
function openNewEventModal(date) {
|
||||||
@@ -702,6 +754,7 @@ function bindEventModal() {
|
|||||||
const allDay = document.getElementById('ev-allday').checked;
|
const allDay = document.getElementById('ev-allday').checked;
|
||||||
const calVal = document.getElementById('ev-calendar').value;
|
const calVal = document.getElementById('ev-calendar').value;
|
||||||
const isLocal = calVal.startsWith('local-');
|
const isLocal = calVal.startsWith('local-');
|
||||||
|
const isGoogle = calVal.startsWith('google-');
|
||||||
const loc = document.getElementById('ev-location').value.trim();
|
const loc = document.getElementById('ev-location').value.trim();
|
||||||
const desc = document.getElementById('ev-description').value.trim();
|
const desc = document.getElementById('ev-description').value.trim();
|
||||||
const color = state.selectedEventColor;
|
const color = state.selectedEventColor;
|
||||||
@@ -723,7 +776,12 @@ function bindEventModal() {
|
|||||||
try {
|
try {
|
||||||
if (state.editingEvent) {
|
if (state.editingEvent) {
|
||||||
const ev = state.editingEvent;
|
const ev = state.editingEvent;
|
||||||
if (ev.source === 'local') {
|
if (ev.source === 'google') {
|
||||||
|
const accId = ev.calendar_id.replace('google-', '');
|
||||||
|
await api.put(`/google/events/${accId}/${encodeURIComponent(ev.id)}`,
|
||||||
|
{ title, start, end, allDay, location: loc, description: desc }
|
||||||
|
);
|
||||||
|
} else if (ev.source === 'local') {
|
||||||
await api.put(`/local/events/${encodeURIComponent(ev.id)}`,
|
await api.put(`/local/events/${encodeURIComponent(ev.id)}`,
|
||||||
{ title, start, end, allDay, location: loc, description: desc, color: color || null }
|
{ title, start, end, allDay, location: loc, description: desc, color: color || null }
|
||||||
);
|
);
|
||||||
@@ -739,6 +797,13 @@ function bindEventModal() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
showToast('Termin aktualisiert');
|
showToast('Termin aktualisiert');
|
||||||
|
} else if (isGoogle) {
|
||||||
|
const accId = parseInt(calVal.replace('google-', ''));
|
||||||
|
await api.post('/google/events', {
|
||||||
|
account_id: accId, title, start, end, allDay,
|
||||||
|
location: loc, description: desc,
|
||||||
|
});
|
||||||
|
showToast('Termin erstellt');
|
||||||
} else if (isLocal) {
|
} else if (isLocal) {
|
||||||
const calId = parseInt(calVal.replace('local-', ''));
|
const calId = parseInt(calVal.replace('local-', ''));
|
||||||
await api.post('/local/events', {
|
await api.post('/local/events', {
|
||||||
@@ -766,7 +831,10 @@ function bindEventModal() {
|
|||||||
if (!ev) return;
|
if (!ev) return;
|
||||||
if (!confirm(`"${ev.title}" wirklich löschen?`)) return;
|
if (!confirm(`"${ev.title}" wirklich löschen?`)) return;
|
||||||
try {
|
try {
|
||||||
if (ev.source === 'local') {
|
if (ev.source === 'google') {
|
||||||
|
const accId = ev.calendar_id.replace('google-', '');
|
||||||
|
await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`);
|
||||||
|
} else if (ev.source === 'local') {
|
||||||
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
|
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
|
||||||
} else if (ev.source === 'ical') {
|
} else if (ev.source === 'ical') {
|
||||||
const subId = ev.calendar_id.replace('ical-', '');
|
const subId = ev.calendar_id.replace('ical-', '');
|
||||||
@@ -954,9 +1022,46 @@ function openSettingsModal() {
|
|||||||
usersSection.classList.add('hidden');
|
usersSection.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render hidden calendars
|
||||||
|
renderHiddenCalendars();
|
||||||
|
|
||||||
openModal('modal-settings');
|
openModal('modal-settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderHiddenCalendars() {
|
||||||
|
const list = document.getElementById('hidden-cals-list');
|
||||||
|
const hidden = [];
|
||||||
|
for (const acc of state.accounts) {
|
||||||
|
for (const cal of acc.calendars) {
|
||||||
|
if (!cal.enabled || cal._hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hidden.length) {
|
||||||
|
list.innerHTML = '<span style="font-size:13px;color:var(--text-3)">Keine ausgeblendeten Kalender</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = hidden.map(c =>
|
||||||
|
`<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0">
|
||||||
|
<span style="font-size:13px">${escHtml(c.acc)} / ${escHtml(c.name)}</span>
|
||||||
|
<button class="btn btn-secondary btn-sm" data-restore-cal="${c.id}">Einblenden</button>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
list.querySelectorAll('[data-restore-cal]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const calId = parseInt(btn.dataset.restoreCal);
|
||||||
|
await api.put(`/caldav/calendars/${calId}`, { enabled: true });
|
||||||
|
for (const acc of state.accounts) {
|
||||||
|
for (const cal of acc.calendars) {
|
||||||
|
if (cal.id === calId) { cal.enabled = true; delete cal._hidden; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderHiddenCalendars();
|
||||||
|
renderCalendarList();
|
||||||
|
fetchAndRender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
const users = await api.get('/users/');
|
const users = await api.get('/users/');
|
||||||
|
|||||||
Reference in New Issue
Block a user