Einige kleine verbesserungen #1

Open
Scarriffle wants to merge 115 commits from beta into master
6 changed files with 235 additions and 114 deletions
Showing only changes of commit 9a59911156 - Show all commits

View File

@@ -78,6 +78,11 @@ def _migrate():
conn.commit() conn.commit()
except Exception: except Exception:
pass pass
try:
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN client_id VARCHAR(500)"))
conn.commit()
except Exception:
pass
_migrate() _migrate()

View File

@@ -192,6 +192,7 @@ class HomeAssistantAccount(Base):
auth_method = Column(String(20), default="token") auth_method = Column(String(20), default="token")
refresh_token = Column(Text, nullable=True) refresh_token = Column(Text, nullable=True)
token_expiry = Column(DateTime, nullable=True) token_expiry = Column(DateTime, nullable=True)
client_id = Column(String(500), nullable=True)
user = relationship("User", back_populates="homeassistant_accounts") user = relationship("User", back_populates="homeassistant_accounts")
calendars = relationship( calendars = relationship(

View File

@@ -1,9 +1,13 @@
import logging import logging
import secrets
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from urllib.parse import urlencode
import requests as http_requests 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 pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -15,44 +19,27 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
HA_DEFAULT_COLOR = "#03a9f4" 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 ────────────────────────────────────────── # ── Auth helpers ──────────────────────────────────────────
def _ha_login(url: str, username: str, password: str) -> tuple: def _ha_refresh(url: str, refresh_token: str, client_id: 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:
"""Refresh grant → (access_token, expires_in)""" """Refresh grant → (access_token, expires_in)"""
resp = http_requests.post( resp = http_requests.post(
f"{url.rstrip('/')}/auth/token", f"{url.rstrip('/')}/auth/token",
data={ data={
"grant_type": "refresh_token", "grant_type": "refresh_token",
"refresh_token": refresh_token, "refresh_token": refresh_token,
"client_id": HA_CLIENT_ID, "client_id": client_id,
}, },
timeout=10, timeout=10,
verify=False, 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: def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str:
"""Return a valid access token, refreshing if necessary.""" """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 return account.token # Long-Lived Token läuft nicht ab
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now: if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now:
return account.token return account.token
# Needs refresh # Needs refresh
try: 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: except Exception as exc:
logger.error("HA token refresh failed for %s: %s", account.name, exc) logger.error("HA token refresh failed for %s: %s", account.name, exc)
raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden") 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): class HAAccountCreate(BaseModel):
name: str name: str
url: str url: str
token: Optional[str] = None token: str
username: Optional[str] = None
password: Optional[str] = None
class HAOAuthStart(BaseModel):
name: str
url: str
client_id: str
redirect_uri: str
class HACalendarUpdate(BaseModel): class HACalendarUpdate(BaseModel):
@@ -221,31 +215,17 @@ def add_account(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
now = datetime.now(timezone.utc) """Create a HA account from a Long-Lived Access Token."""
remote_cals = _ha_get_calendars(data.url, data.token)
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)
account = models.HomeAssistantAccount( account = models.HomeAssistantAccount(
user_id=current_user.id, user_id=current_user.id,
name=data.name, name=data.name,
url=data.url, url=data.url,
token=access_token, token=data.token,
auth_method=auth_method, auth_method="token",
refresh_token=stored_refresh, refresh_token=None,
token_expiry=token_expiry, token_expiry=None,
) )
db.add(account) db.add(account)
db.flush() db.flush()
@@ -267,6 +247,111 @@ def add_account(
return _account_dict(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}") @router.delete("/accounts/{account_id}")
def delete_account( def delete_account(
account_id: int, account_id: int,

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- APP_VERSION: update here + version.js on every release --> <!-- 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="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="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css" />
<link rel="stylesheet" href="/static/css/app.css" /> <link rel="stylesheet" href="/static/css/app.css" />
@@ -71,7 +71,7 @@
<button type="submit" class="btn btn-primary btn-full">Anmelden</button> <button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form> </form>
</div> </div>
<button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v1</button> <button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v2</button>
</div> </div>
<!-- ─── MAIN APP ──────────────────────────────────────────── --> <!-- ─── MAIN APP ──────────────────────────────────────────── -->
@@ -118,7 +118,7 @@
<span data-i18n="btn_profile">Profil</span> <span data-i18n="btn_profile">Profil</span>
</button> </button>
<button class="dropdown-item" id="btn-logout"> <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> <span data-i18n="btn_logout">Abmelden</span>
</button> </button>
</div> </div>
@@ -173,7 +173,7 @@
<div id="cal-list-items"></div> <div id="cal-list-items"></div>
</div> </div>
</div> </div>
<button class="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v1</button> <button class="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v2</button>
</aside> </aside>
<!-- MAIN VIEW --> <!-- MAIN VIEW -->
@@ -208,7 +208,7 @@
<input type="hidden" id="ev-start" /> <input type="hidden" id="ev-start" />
<div class="dt-display" id="ev-start-display" tabindex="0" role="button"> <div class="dt-display" id="ev-start-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <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> </div>
<div class="form-group half"> <div class="form-group half">
@@ -216,7 +216,7 @@
<input type="hidden" id="ev-end" /> <input type="hidden" id="ev-end" />
<div class="dt-display" id="ev-end-display" tabindex="0" role="button"> <div class="dt-display" id="ev-end-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <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> </div>
</div> </div>
@@ -226,7 +226,7 @@
<input type="hidden" id="ev-start-date" /> <input type="hidden" id="ev-start-date" />
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button"> <div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <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> </div>
<div class="form-group half"> <div class="form-group half">
@@ -234,7 +234,7 @@
<input type="hidden" id="ev-end-date" /> <input type="hidden" id="ev-end-date" />
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button"> <div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <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> </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> <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>
<button class="icon-btn popup-action" id="popup-copy" title="Kopieren nach…"> <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>
<button class="icon-btn popup-action" id="popup-delete" title="Löschen"> <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>
<button class="icon-btn popup-close" id="popup-close">&times;</button> <button class="icon-btn popup-close" id="popup-close">&times;</button>
</div> </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="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"> <div style="display:flex;gap:20px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--text-1);font-size:14px"> <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>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--text-1);font-size:14px"> <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> </label>
</div> </div>
</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> <label>Long-Lived Access Token</label>
<input type="password" id="ha-account-token" placeholder="Token aus Profil → Sicherheit" autocomplete="off" /> <input type="password" id="ha-account-token" placeholder="Token aus Profil → Sicherheit" autocomplete="off" />
</div> </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 id="ha-account-error" class="form-error hidden"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-ghost" data-modal="modal-ha-account">Abbrechen</button> <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> </div>
</div> </div>
@@ -689,7 +682,7 @@
<div class="totp-secret-row"> <div class="totp-secret-row">
<code id="2fa-secret-code"></code> <code id="2fa-secret-code"></code>
<button class="btn btn-ghost btn-sm" id="2fa-copy-secret" title="Kopieren"> <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> </button>
</div> </div>
</div> </div>
@@ -765,7 +758,7 @@
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p> <a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
</div> </div>
<div class="modal-footer" style="justify-content:space-between;align-items:center"> <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> <button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
</div> </div>
</div> </div>

View File

@@ -75,6 +75,32 @@ export async function initCalendar() {
bindHAAccountModal(); bindHAAccountModal();
bindSettingsModal(); bindSettingsModal();
bindProfileModal(); 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 ─────────────────────────────────────────── // ── Event cache ───────────────────────────────────────────
@@ -1266,30 +1292,32 @@ function openHAAccountModal() {
document.getElementById('ha-account-name').value = ''; document.getElementById('ha-account-name').value = '';
document.getElementById('ha-account-url').value = ''; document.getElementById('ha-account-url').value = '';
document.getElementById('ha-account-token').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'); document.getElementById('ha-account-error').classList.add('hidden');
// Reset to token method // Reset to OAuth method
document.getElementById('ha-auth-token').checked = true; document.getElementById('ha-auth-oauth').checked = true;
document.getElementById('ha-token-group').classList.remove('hidden'); document.getElementById('ha-oauth-info').classList.remove('hidden');
document.getElementById('ha-credentials-group').classList.add('hidden'); document.getElementById('ha-token-group').classList.add('hidden');
document.getElementById('ha-account-save').textContent = 'Mit Home Assistant anmelden';
openModal('modal-ha-account'); openModal('modal-ha-account');
} }
function bindHAAccountModal() { function bindHAAccountModal() {
// Toggle auth method fields // Toggle auth method fields + save button label
document.querySelectorAll('[name="ha-auth-method"]').forEach(r => { document.querySelectorAll('[name="ha-auth-method"]').forEach(r => {
r.addEventListener('change', () => { r.addEventListener('change', () => {
const isPw = document.getElementById('ha-auth-password').checked; const isOAuth = document.getElementById('ha-auth-oauth').checked;
document.getElementById('ha-token-group').classList.toggle('hidden', isPw); document.getElementById('ha-oauth-info').classList.toggle('hidden', !isOAuth);
document.getElementById('ha-credentials-group').classList.toggle('hidden', !isPw); 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 () => { document.getElementById('ha-account-save').onclick = async () => {
const name = document.getElementById('ha-account-name').value.trim(); const name = document.getElementById('ha-account-name').value.trim();
const url = document.getElementById('ha-account-url').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'); const errEl = document.getElementById('ha-account-error');
if (!name || !url) { if (!name || !url) {
@@ -1297,34 +1325,43 @@ function bindHAAccountModal() {
errEl.classList.remove('hidden'); errEl.classList.remove('hidden');
return; 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'); errEl.classList.add('hidden');
const saveBtn = document.getElementById('ha-account-save'); const saveBtn = document.getElementById('ha-account-save');
saveBtn.disabled = true; 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…'; saveBtn.textContent = 'Verbinde…';
try { try {
const account = await api.post('/homeassistant/accounts', body); const account = await api.post('/homeassistant/accounts', { name, url, token });
if (!account) return; if (!account) return;
state.haAccounts.push(account); state.haAccounts.push(account);
renderCalendarList(); renderCalendarList();

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change // Increment APP_VERSION with every code change
export const APP_VERSION = 'v1'; export const APP_VERSION = 'v2';