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:
Scarriffle
2026-04-24 12:57:38 +02:00
parent 3d4fdb3f8f
commit 58c7cbc38c
6 changed files with 235 additions and 114 deletions

View File

@@ -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()

View File

@@ -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(

View File

@@ -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,

View File

@@ -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()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v1</button>
<button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;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()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v1</button>
<button class="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;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">&times;</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>

View File

@@ -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();

View File

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