feat(ha): OAuth Authorization-Code-Flow statt kaputtem Password-Grant
Home Assistant unterstützt keinen Password-Grant — deshalb kam immer "Ungültige Anmeldedaten", egal was eingegeben wurde. Jetzt wird der Nutzer nach demselben Muster wie bei Google zur HA-Login-Seite weitergeleitet, meldet sich dort an und kommt zurück zu Calendarr. Änderungen: - Neuer POST /api/homeassistant/auth-url und GET /callback Endpoint - Account speichert client_id für spätere Token-Refreshes - Modal: "Benutzername/Passwort" → "Mit Home Assistant anmelden" - Frontend behandelt ?ha_connected=1 / ?ha_error=... nach Rückkehr - Version v1 → v2
This commit is contained in:
@@ -78,6 +78,11 @@ def _migrate():
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN client_id VARCHAR(500)"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_migrate()
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ class HomeAssistantAccount(Base):
|
||||
auth_method = Column(String(20), default="token")
|
||||
refresh_token = Column(Text, nullable=True)
|
||||
token_expiry = Column(DateTime, nullable=True)
|
||||
client_id = Column(String(500), nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="homeassistant_accounts")
|
||||
calendars = relationship(
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
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
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -15,44 +19,27 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
HA_DEFAULT_COLOR = "#03a9f4"
|
||||
HA_CLIENT_ID = "http://localhost/"
|
||||
|
||||
# In-memory store for pending OAuth states (short-lived, ~10 min TTL)
|
||||
_pending_oauth: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _cleanup_pending():
|
||||
now = time.time()
|
||||
for k in [k for k, v in _pending_oauth.items() if v["expires"] < now]:
|
||||
_pending_oauth.pop(k, None)
|
||||
|
||||
|
||||
# ── Auth helpers ──────────────────────────────────────────
|
||||
|
||||
def _ha_login(url: str, username: str, password: str) -> tuple:
|
||||
"""Password grant → (access_token, refresh_token, expires_in)"""
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{url.rstrip('/')}/auth/token",
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"username": username,
|
||||
"password": password,
|
||||
"client_id": HA_CLIENT_ID,
|
||||
},
|
||||
timeout=10,
|
||||
verify=False,
|
||||
)
|
||||
except http_requests.exceptions.ConnectionError:
|
||||
raise HTTPException(503, "Home Assistant nicht erreichbar")
|
||||
except http_requests.exceptions.Timeout:
|
||||
raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)")
|
||||
if resp.status_code in (400, 401):
|
||||
raise HTTPException(400, "Ungültige Anmeldedaten")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["access_token"], data.get("refresh_token", ""), data.get("expires_in", 1800)
|
||||
|
||||
|
||||
def _ha_refresh(url: str, refresh_token: str) -> tuple:
|
||||
def _ha_refresh(url: str, refresh_token: str, client_id: str) -> tuple:
|
||||
"""Refresh grant → (access_token, expires_in)"""
|
||||
resp = http_requests.post(
|
||||
f"{url.rstrip('/')}/auth/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": HA_CLIENT_ID,
|
||||
"client_id": client_id,
|
||||
},
|
||||
timeout=10,
|
||||
verify=False,
|
||||
@@ -64,14 +51,16 @@ def _ha_refresh(url: str, refresh_token: str) -> tuple:
|
||||
|
||||
def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str:
|
||||
"""Return a valid access token, refreshing if necessary."""
|
||||
if account.auth_method != "password":
|
||||
if account.auth_method != "oauth":
|
||||
return account.token # Long-Lived Token läuft nicht ab
|
||||
now = datetime.now(timezone.utc)
|
||||
if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now:
|
||||
return account.token
|
||||
# Needs refresh
|
||||
try:
|
||||
access_token, expires_in = _ha_refresh(account.url, account.refresh_token)
|
||||
access_token, expires_in = _ha_refresh(
|
||||
account.url, account.refresh_token, account.client_id or ""
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("HA token refresh failed for %s: %s", account.name, exc)
|
||||
raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden")
|
||||
@@ -188,9 +177,14 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict:
|
||||
class HAAccountCreate(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
token: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
token: str
|
||||
|
||||
|
||||
class HAOAuthStart(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
client_id: str
|
||||
redirect_uri: str
|
||||
|
||||
|
||||
class HACalendarUpdate(BaseModel):
|
||||
@@ -221,31 +215,17 @@ def add_account(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if data.username and data.password:
|
||||
access_token, refresh_tok, expires_in = _ha_login(data.url, data.username, data.password)
|
||||
auth_method = "password"
|
||||
stored_refresh = refresh_tok
|
||||
token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc)
|
||||
elif data.token:
|
||||
access_token = data.token
|
||||
auth_method = "token"
|
||||
stored_refresh = None
|
||||
token_expiry = None
|
||||
else:
|
||||
raise HTTPException(400, "Token oder Benutzername/Passwort erforderlich")
|
||||
|
||||
remote_cals = _ha_get_calendars(data.url, access_token)
|
||||
"""Create a HA account from a Long-Lived Access Token."""
|
||||
remote_cals = _ha_get_calendars(data.url, data.token)
|
||||
|
||||
account = models.HomeAssistantAccount(
|
||||
user_id=current_user.id,
|
||||
name=data.name,
|
||||
url=data.url,
|
||||
token=access_token,
|
||||
auth_method=auth_method,
|
||||
refresh_token=stored_refresh,
|
||||
token_expiry=token_expiry,
|
||||
token=data.token,
|
||||
auth_method="token",
|
||||
refresh_token=None,
|
||||
token_expiry=None,
|
||||
)
|
||||
db.add(account)
|
||||
db.flush()
|
||||
@@ -267,6 +247,111 @@ def add_account(
|
||||
return _account_dict(account)
|
||||
|
||||
|
||||
@router.post("/auth-url")
|
||||
def oauth_start(
|
||||
data: HAOAuthStart,
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Start the OAuth flow: store pending state, return HA authorization URL."""
|
||||
_cleanup_pending()
|
||||
state_token = secrets.token_urlsafe(32)
|
||||
_pending_oauth[state_token] = {
|
||||
"user_id": current_user.id,
|
||||
"ha_url": data.url.rstrip('/'),
|
||||
"name": data.name,
|
||||
"client_id": data.client_id,
|
||||
"redirect_uri": data.redirect_uri,
|
||||
"expires": time.time() + 600,
|
||||
}
|
||||
params = {
|
||||
"client_id": data.client_id,
|
||||
"redirect_uri": data.redirect_uri,
|
||||
"state": state_token,
|
||||
"response_type": "code",
|
||||
}
|
||||
return {"url": f"{data.url.rstrip('/')}/auth/authorize?{urlencode(params)}"}
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
def oauth_callback(
|
||||
request: Request,
|
||||
code: str = Query(""),
|
||||
state: str = Query(""),
|
||||
error: str = Query(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Callback from Home Assistant after user authorization."""
|
||||
if error or not code:
|
||||
return RedirectResponse(url=f"/?ha_error={error or 'no_code'}", status_code=302)
|
||||
|
||||
pending = _pending_oauth.pop(state, None)
|
||||
if not pending or pending["expires"] < time.time():
|
||||
return RedirectResponse(url="/?ha_error=state_expired", status_code=302)
|
||||
|
||||
ha_url = pending["ha_url"]
|
||||
client_id = pending["client_id"]
|
||||
|
||||
# Exchange code for tokens
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{ha_url}/auth/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
},
|
||||
timeout=15,
|
||||
verify=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("HA token exchange connection error: %s", exc)
|
||||
return RedirectResponse(url="/?ha_error=ha_unreachable", status_code=302)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error("HA token exchange failed (%s): %s", resp.status_code, resp.text)
|
||||
return RedirectResponse(url="/?ha_error=token_exchange_failed", status_code=302)
|
||||
|
||||
tokens = resp.json()
|
||||
access_token = tokens["access_token"]
|
||||
refresh_token = tokens.get("refresh_token", "")
|
||||
expires_in = tokens.get("expires_in", 1800)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
remote_cals = _ha_get_calendars(ha_url, access_token)
|
||||
except HTTPException as exc:
|
||||
logger.error("HA calendar fetch failed after OAuth: %s", exc.detail)
|
||||
return RedirectResponse(url="/?ha_error=calendars_failed", status_code=302)
|
||||
|
||||
account = models.HomeAssistantAccount(
|
||||
user_id=pending["user_id"],
|
||||
name=pending["name"],
|
||||
url=ha_url,
|
||||
token=access_token,
|
||||
auth_method="oauth",
|
||||
refresh_token=refresh_token,
|
||||
token_expiry=datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc),
|
||||
client_id=client_id,
|
||||
)
|
||||
db.add(account)
|
||||
db.flush()
|
||||
|
||||
for cal in remote_cals:
|
||||
entity_id = cal.get("entity_id", "")
|
||||
if not entity_id:
|
||||
continue
|
||||
db.add(models.HomeAssistantCalendar(
|
||||
account_id=account.id,
|
||||
entity_id=entity_id,
|
||||
name=cal.get("name") or entity_id,
|
||||
color=None,
|
||||
enabled=True,
|
||||
))
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse(url="/?ha_connected=1", status_code=302)
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}")
|
||||
def delete_account(
|
||||
account_id: int,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- APP_VERSION: update here + version.js on every release -->
|
||||
<title>Calendarr v1</title>
|
||||
<title>Calendarr v2</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css" />
|
||||
<link rel="stylesheet" href="/static/css/app.css" />
|
||||
@@ -71,7 +71,7 @@
|
||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v1</button>
|
||||
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v2</button>
|
||||
</div>
|
||||
|
||||
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
||||
@@ -118,7 +118,7 @@
|
||||
<span data-i18n="btn_profile">Profil</span>
|
||||
</button>
|
||||
<button class="dropdown-item" id="btn-logout">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v24c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
||||
<span data-i18n="btn_logout">Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -173,7 +173,7 @@
|
||||
<div id="cal-list-items"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices · v1</button>
|
||||
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices · v2</button>
|
||||
</aside>
|
||||
|
||||
<!-- MAIN VIEW -->
|
||||
@@ -208,7 +208,7 @@
|
||||
<input type="hidden" id="ev-start" />
|
||||
<div class="dt-display" id="ev-start-display" tabindex="0" role="button">
|
||||
<span class="dt-display-text">—</span>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v5H7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group half">
|
||||
@@ -216,7 +216,7 @@
|
||||
<input type="hidden" id="ev-end" />
|
||||
<div class="dt-display" id="ev-end-display" tabindex="0" role="button">
|
||||
<span class="dt-display-text">—</span>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v5H7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,7 +226,7 @@
|
||||
<input type="hidden" id="ev-start-date" />
|
||||
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
|
||||
<span class="dt-display-text">—</span>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v5H7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group half">
|
||||
@@ -234,7 +234,7 @@
|
||||
<input type="hidden" id="ev-end-date" />
|
||||
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
|
||||
<span class="dt-display-text">—</span>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v5H7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,10 +276,10 @@
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn popup-action" id="popup-copy" title="Kopieren nach…">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v24h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v24c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v24z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn popup-action" id="popup-delete" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><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"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v22zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn popup-close" id="popup-close">×</button>
|
||||
</div>
|
||||
@@ -421,32 +421,25 @@
|
||||
<div style="font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:.5px;color:var(--text-2);margin-bottom:8px">Anmeldemethode</div>
|
||||
<div style="display:flex;gap:20px">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--text-1);font-size:14px">
|
||||
<input type="radio" name="ha-auth-method" value="token" id="ha-auth-token" checked style="width:auto;accent-color:var(--primary)" /> Long-Lived Token
|
||||
<input type="radio" name="ha-auth-method" value="oauth" id="ha-auth-oauth" checked style="width:auto;accent-color:var(--primary)" /> Mit Home Assistant anmelden
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--text-1);font-size:14px">
|
||||
<input type="radio" name="ha-auth-method" value="password" id="ha-auth-password" style="width:auto;accent-color:var(--primary)" /> Benutzername/Passwort
|
||||
<input type="radio" name="ha-auth-method" value="token" id="ha-auth-token" style="width:auto;accent-color:var(--primary)" /> Long-Lived Token
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="ha-token-group">
|
||||
<div id="ha-oauth-info" style="margin-bottom:16px;padding:10px 12px;background:var(--bg-app);border-radius:var(--radius-sm);font-size:13px;color:var(--text-2);line-height:1.5">
|
||||
Du wirst zur Login-Seite deiner Home Assistant Instanz weitergeleitet und nach erfolgreichem Login wieder zurück zu Calendarr.
|
||||
</div>
|
||||
<div class="form-group hidden" id="ha-token-group">
|
||||
<label>Long-Lived Access Token</label>
|
||||
<input type="password" id="ha-account-token" placeholder="Token aus Profil → Sicherheit" autocomplete="off" />
|
||||
</div>
|
||||
<div id="ha-credentials-group" class="hidden">
|
||||
<div class="form-group">
|
||||
<label>Benutzername</label>
|
||||
<input type="text" id="ha-account-username" autocomplete="username" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Passwort</label>
|
||||
<input type="password" id="ha-account-userpass" autocomplete="current-password" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="ha-account-error" class="form-error hidden"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" data-modal="modal-ha-account">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="ha-account-save">Verbinden</button>
|
||||
<button class="btn btn-primary" id="ha-account-save">Mit Home Assistant anmelden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -689,7 +682,7 @@
|
||||
<div class="totp-secret-row">
|
||||
<code id="2fa-secret-code"></code>
|
||||
<button class="btn btn-ghost btn-sm" id="2fa-copy-secret" title="Kopieren">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M16 1H4c-1.1 0-2 .9-2 2v24h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v24c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v24z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -765,7 +758,7 @@
|
||||
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
|
||||
</div>
|
||||
<div class="modal-footer" style="justify-content:space-between;align-items:center">
|
||||
<span style="font-size:12px;color:var(--text-3)">Calendarr v1</span>
|
||||
<span style="font-size:12px;color:var(--text-3)">Calendarr v2</span>
|
||||
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +75,32 @@ export async function initCalendar() {
|
||||
bindHAAccountModal();
|
||||
bindSettingsModal();
|
||||
bindProfileModal();
|
||||
handleHAOAuthReturn();
|
||||
}
|
||||
|
||||
function handleHAOAuthReturn() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const errMap = {
|
||||
no_code: 'Home Assistant hat keinen Autorisierungscode zurückgegeben',
|
||||
state_expired: 'Die Anmeldung ist abgelaufen, bitte erneut versuchen',
|
||||
ha_unreachable: 'Home Assistant nicht erreichbar',
|
||||
token_exchange_failed: 'Token-Austausch mit Home Assistant fehlgeschlagen',
|
||||
calendars_failed: 'Kalender konnten nicht geladen werden',
|
||||
};
|
||||
if (params.has('ha_connected')) {
|
||||
showToast('Home Assistant verbunden');
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
fetchAndRender(true);
|
||||
api.get('/homeassistant/accounts').then(accs => {
|
||||
state.haAccounts = accs || [];
|
||||
renderCalendarList();
|
||||
renderAllAccounts?.();
|
||||
}).catch(() => {});
|
||||
} else if (params.has('ha_error')) {
|
||||
const code = params.get('ha_error');
|
||||
showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true);
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event cache ───────────────────────────────────────────
|
||||
@@ -1266,30 +1292,32 @@ function openHAAccountModal() {
|
||||
document.getElementById('ha-account-name').value = '';
|
||||
document.getElementById('ha-account-url').value = '';
|
||||
document.getElementById('ha-account-token').value = '';
|
||||
document.getElementById('ha-account-username').value = '';
|
||||
document.getElementById('ha-account-userpass').value = '';
|
||||
document.getElementById('ha-account-error').classList.add('hidden');
|
||||
// Reset to token method
|
||||
document.getElementById('ha-auth-token').checked = true;
|
||||
document.getElementById('ha-token-group').classList.remove('hidden');
|
||||
document.getElementById('ha-credentials-group').classList.add('hidden');
|
||||
// Reset to OAuth method
|
||||
document.getElementById('ha-auth-oauth').checked = true;
|
||||
document.getElementById('ha-oauth-info').classList.remove('hidden');
|
||||
document.getElementById('ha-token-group').classList.add('hidden');
|
||||
document.getElementById('ha-account-save').textContent = 'Mit Home Assistant anmelden';
|
||||
openModal('modal-ha-account');
|
||||
}
|
||||
|
||||
function bindHAAccountModal() {
|
||||
// Toggle auth method fields
|
||||
// Toggle auth method fields + save button label
|
||||
document.querySelectorAll('[name="ha-auth-method"]').forEach(r => {
|
||||
r.addEventListener('change', () => {
|
||||
const isPw = document.getElementById('ha-auth-password').checked;
|
||||
document.getElementById('ha-token-group').classList.toggle('hidden', isPw);
|
||||
document.getElementById('ha-credentials-group').classList.toggle('hidden', !isPw);
|
||||
const isOAuth = document.getElementById('ha-auth-oauth').checked;
|
||||
document.getElementById('ha-oauth-info').classList.toggle('hidden', !isOAuth);
|
||||
document.getElementById('ha-token-group').classList.toggle('hidden', isOAuth);
|
||||
document.getElementById('ha-account-save').textContent = isOAuth
|
||||
? 'Mit Home Assistant anmelden'
|
||||
: 'Verbinden';
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('ha-account-save').onclick = async () => {
|
||||
const name = document.getElementById('ha-account-name').value.trim();
|
||||
const url = document.getElementById('ha-account-url').value.trim();
|
||||
const isPw = document.getElementById('ha-auth-password').checked;
|
||||
const isOAuth = document.getElementById('ha-auth-oauth').checked;
|
||||
const errEl = document.getElementById('ha-account-error');
|
||||
|
||||
if (!name || !url) {
|
||||
@@ -1297,34 +1325,43 @@ function bindHAAccountModal() {
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = { name, url };
|
||||
if (isPw) {
|
||||
const username = document.getElementById('ha-account-username').value.trim();
|
||||
const password = document.getElementById('ha-account-userpass').value.trim();
|
||||
if (!username || !password) {
|
||||
errEl.textContent = 'Bitte Benutzername und Passwort ausfüllen';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
body.username = username;
|
||||
body.password = password;
|
||||
} else {
|
||||
const token = document.getElementById('ha-account-token').value.trim();
|
||||
if (!token) {
|
||||
errEl.textContent = 'Bitte Access Token ausfüllen';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
body.token = token;
|
||||
}
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
const saveBtn = document.getElementById('ha-account-save');
|
||||
saveBtn.disabled = true;
|
||||
|
||||
if (isOAuth) {
|
||||
saveBtn.textContent = 'Weiterleiten…';
|
||||
try {
|
||||
const base = window.location.origin;
|
||||
const resp = await api.post('/homeassistant/auth-url', {
|
||||
name,
|
||||
url,
|
||||
client_id: base + '/',
|
||||
redirect_uri: base + '/api/homeassistant/callback',
|
||||
});
|
||||
if (!resp) return;
|
||||
window.location.href = resp.url;
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message || 'Fehler beim Starten der Anmeldung';
|
||||
errEl.classList.remove('hidden');
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Mit Home Assistant anmelden';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Long-Lived Token flow
|
||||
const token = document.getElementById('ha-account-token').value.trim();
|
||||
if (!token) {
|
||||
errEl.textContent = 'Bitte Access Token ausfüllen';
|
||||
errEl.classList.remove('hidden');
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
saveBtn.textContent = 'Verbinde…';
|
||||
try {
|
||||
const account = await api.post('/homeassistant/accounts', body);
|
||||
const account = await api.post('/homeassistant/accounts', { name, url, token });
|
||||
if (!account) return;
|
||||
state.haAccounts.push(account);
|
||||
renderCalendarList();
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v1';
|
||||
export const APP_VERSION = 'v2';
|
||||
|
||||
Reference in New Issue
Block a user