Profilseite mit Avatar, Passwort-Änderung und TOTP 2FA

- Neues Profil-Modal: Avatar-Upload, E-Mail bearbeiten, Kalender-Übersicht
- Passwort ändern mit Validierung des aktuellen Passworts
- TOTP 2FA: QR-Code + manueller Schlüssel, Aktivierung/Deaktivierung
- Login-Flow unterstützt 2FA-Code (neuer JSON-Endpoint /auth/login)
- User-Dropdown mit Profil-Link statt confirm()-Dialog
- Kalenderfarben in Sidebar editierbar (Color-Picker auf Farbpunkt)
- Monatsansicht nutzt volle Höhe (#view-container flex fix)
- requirements.txt: passlib durch bcrypt ersetzt, pyotp/qrcode/Pillow hinzugefügt
This commit is contained in:
2026-03-26 14:10:53 +01:00
parent 8e200e9d11
commit 128f1b468a
10 changed files with 738 additions and 18 deletions

View File

@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
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, settings_router, users_router from routers import auth_router, caldav_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -24,6 +24,7 @@ app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
app.include_router(users_router.router, prefix="/api/users", tags=["users"]) app.include_router(users_router.router, prefix="/api/users", tags=["users"])
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"]) app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
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")

View File

@@ -11,6 +11,9 @@ class User(Base):
email = Column(String(100), unique=True, nullable=True) email = Column(String(100), unique=True, nullable=True)
password_hash = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
avatar_filename = Column(String(255), nullable=True)
totp_secret = Column(String(32), nullable=True)
totp_enabled = Column(Boolean, default=False)
caldav_accounts = relationship( caldav_accounts = relationship(
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan" "CalDAVAccount", back_populates="user", cascade="all, delete-orphan"

View File

@@ -1,5 +1,6 @@
from typing import Optional from typing import Optional
import pyotp
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel from pydantic import BaseModel
@@ -18,6 +19,12 @@ class SetupRequest(BaseModel):
email: Optional[str] = None email: Optional[str] = None
class LoginRequest(BaseModel):
username: str
password: str
totp_code: Optional[str] = None
def _user_dict(user: models.User) -> dict: def _user_dict(user: models.User) -> dict:
return {"id": user.id, "username": user.username, "is_admin": user.is_admin} return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
@@ -59,6 +66,37 @@ def login(
detail="Incorrect username or password", detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="2fa_required",
)
token = create_access_token({"sub": user.username})
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
@router.post("/login")
def login_json(req: LoginRequest, db: Session = Depends(get_db)):
user = (
db.query(models.User).filter(models.User.username == req.username).first()
)
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Benutzername oder Passwort falsch",
)
if user.totp_enabled:
if not req.totp_code:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="2fa_required",
)
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(req.totp_code, valid_window=1):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungültiger 2FA-Code",
)
token = create_access_token({"sub": user.username}) token = create_access_token({"sub": user.username})
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
@@ -70,4 +108,6 @@ def me(current_user: models.User = Depends(get_current_user)):
"username": current_user.username, "username": current_user.username,
"email": current_user.email, "email": current_user.email,
"is_admin": current_user.is_admin, "is_admin": current_user.is_admin,
"has_avatar": current_user.avatar_filename is not None,
"totp_enabled": current_user.totp_enabled,
} }

View File

@@ -0,0 +1,203 @@
import io
import base64
from pathlib import Path
from typing import Optional
import pyotp
import qrcode
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_user, get_password_hash, verify_password
from database import DATA_DIR, get_db
router = APIRouter()
AVATAR_DIR = DATA_DIR / "avatars"
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2 MB
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
# ── Schemas ───────────────────────────────────────────────
class ProfileUpdate(BaseModel):
email: Optional[str] = None
class PasswordChange(BaseModel):
current_password: str
new_password: str
class TOTPVerify(BaseModel):
code: str
class TOTPDisable(BaseModel):
password: str
# ── Profile ───────────────────────────────────────────────
@router.get("/")
def get_profile(current_user: models.User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"is_admin": current_user.is_admin,
"has_avatar": current_user.avatar_filename is not None,
"totp_enabled": current_user.totp_enabled,
}
@router.put("/")
def update_profile(
data: ProfileUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
if data.email is not None:
current_user.email = data.email or None
db.commit()
return {"ok": True}
# ── Avatar ────────────────────────────────────────────────
@router.post("/avatar")
async def upload_avatar(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(400, "Nur JPEG, PNG oder WebP erlaubt")
data = await file.read()
if len(data) > MAX_AVATAR_SIZE:
raise HTTPException(400, "Datei zu groß (max. 2 MB)")
# Resize to 256x256
img = Image.open(io.BytesIO(data))
img = img.convert("RGB")
img.thumbnail((256, 256))
filename = f"user_{current_user.id}.jpg"
path = AVATAR_DIR / filename
img.save(str(path), "JPEG", quality=85)
current_user.avatar_filename = filename
db.commit()
return {"ok": True}
@router.get("/avatar")
def get_avatar(current_user: models.User = Depends(get_current_user)):
if not current_user.avatar_filename:
raise HTTPException(404, "Kein Profilbild")
path = AVATAR_DIR / current_user.avatar_filename
if not path.exists():
raise HTTPException(404, "Kein Profilbild")
return FileResponse(str(path), media_type="image/jpeg")
@router.get("/avatar/{user_id}")
def get_user_avatar(user_id: int, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user or not user.avatar_filename:
raise HTTPException(404, "Kein Profilbild")
path = AVATAR_DIR / user.avatar_filename
if not path.exists():
raise HTTPException(404, "Kein Profilbild")
return FileResponse(str(path), media_type="image/jpeg")
@router.delete("/avatar")
def delete_avatar(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
if current_user.avatar_filename:
path = AVATAR_DIR / current_user.avatar_filename
if path.exists():
path.unlink()
current_user.avatar_filename = None
db.commit()
return {"ok": True}
# ── Password ──────────────────────────────────────────────
@router.post("/password")
def change_password(
data: PasswordChange,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(400, "Aktuelles Passwort ist falsch")
if len(data.new_password) < 6:
raise HTTPException(400, "Passwort muss mindestens 6 Zeichen haben")
current_user.password_hash = get_password_hash(data.new_password)
db.commit()
return {"ok": True}
# ── 2FA / TOTP ───────────────────────────────────────────
@router.post("/2fa/setup")
def setup_totp(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
secret = pyotp.random_base32()
current_user.totp_secret = secret
db.commit()
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name=current_user.username, issuer_name="Calendarr")
# Generate QR code as base64
qr = qrcode.make(uri, box_size=6, border=2)
buf = io.BytesIO()
qr.save(buf, format="PNG")
qr_b64 = base64.b64encode(buf.getvalue()).decode()
return {
"secret": secret,
"qr_code": f"data:image/png;base64,{qr_b64}",
}
@router.post("/2fa/enable")
def enable_totp(
data: TOTPVerify,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
if not current_user.totp_secret:
raise HTTPException(400, "2FA wurde noch nicht eingerichtet")
totp = pyotp.TOTP(current_user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
current_user.totp_enabled = True
db.commit()
return {"ok": True}
@router.post("/2fa/disable")
def disable_totp(
data: TOTPDisable,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
if not verify_password(data.password, current_user.password_hash):
raise HTTPException(400, "Passwort ist falsch")
current_user.totp_secret = None
current_user.totp_enabled = False
db.commit()
return {"ok": True}

View File

@@ -212,6 +212,7 @@ a { color: var(--primary); text-decoration: none; }
.view-btn:hover { background: var(--bg-hover); color: var(--text-1); } .view-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.view-btn.active { background: var(--primary-dim); color: var(--primary); } .view-btn.active { background: var(--primary-dim); color: var(--primary); }
.user-menu-wrapper { position: relative; }
.user-avatar { .user-avatar {
width: 34px; height: 34px; border-radius: 50%; width: 34px; height: 34px; border-radius: 50%;
background: var(--primary); background: var(--primary);
@@ -219,6 +220,26 @@ a { color: var(--primary); text-decoration: none; }
font-weight: 600; font-size: 14px; color: #fff; font-weight: 600; font-size: 14px; color: #fff;
cursor: pointer; user-select: none; flex-shrink: 0; cursor: pointer; user-select: none; flex-shrink: 0;
} }
.user-dropdown {
position: absolute; top: 42px; right: 0;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); min-width: 180px;
box-shadow: 0 8px 24px rgba(0,0,0,.4); z-index: 200;
overflow: hidden;
}
.dropdown-user {
padding: 12px 16px; font-size: 13px; font-weight: 600;
color: var(--text-1); border-bottom: 1px solid var(--border);
}
.dropdown-item {
display: flex; align-items: center; gap: 10px;
width: 100%; padding: 10px 16px; border: none;
background: none; color: var(--text-2); font-size: 13px;
cursor: pointer; transition: background var(--transition);
}
.dropdown-item:hover { background: var(--bg-hover); color: var(--text-1); }
.dropdown-item svg { flex-shrink: 0;
}
/* ── Layout ─────────────────────────────────────────────── */ /* ── Layout ─────────────────────────────────────────────── */
#app { #app {
@@ -239,6 +260,7 @@ a { color: var(--primary); text-decoration: none; }
.sidebar.collapsed { transform: translateX(calc(-1 * var(--sidebar-w))); margin-right: calc(-1 * var(--sidebar-w)); } .sidebar.collapsed { transform: translateX(calc(-1 * var(--sidebar-w))); margin-right: calc(-1 * var(--sidebar-w)); }
.sidebar-inner { padding-bottom: 24px; } .sidebar-inner { padding-bottom: 24px; }
.main-view { flex: 1; overflow: auto; display: flex; flex-direction: column; } .main-view { flex: 1; overflow: auto; display: flex; flex-direction: column; }
#view-container { flex: 1; display: flex; flex-direction: column; }
/* ── Mini Calendar ──────────────────────────────────────── */ /* ── Mini Calendar ──────────────────────────────────────── */
.mini-cal { padding: 12px 16px; } .mini-cal { padding: 12px 16px; }
@@ -285,8 +307,15 @@ a { color: var(--primary); text-decoration: none; }
margin-right: 12px; margin-right: 12px;
} }
.cal-item:hover { background: var(--bg-hover); } .cal-item:hover { background: var(--bg-hover); }
.cal-item-dot-wrapper { position: relative; flex-shrink: 0; }
.cal-item-dot { .cal-item-dot {
width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; cursor: pointer;
}
.cal-item-dot:hover { outline: 2px solid var(--text-2); outline-offset: 1px; }
.cal-color-input {
position: absolute; top: 0; left: 0;
width: 14px; height: 14px; opacity: 0;
pointer-events: none;
} }
.cal-item input[type=checkbox] { accent-color: var(--primary); width: 14px; height: 14px; } .cal-item input[type=checkbox] { accent-color: var(--primary); width: 14px; height: 14px; }
.cal-item-name { font-size: 13px; flex: 1; color: var(--text-1); } .cal-item-name { font-size: 13px; flex: 1; color: var(--text-1); }
@@ -541,6 +570,55 @@ a { color: var(--primary); text-decoration: none; }
.users-list-item .uemail { font-size: 12px; color: var(--text-2); } .users-list-item .uemail { font-size: 12px; color: var(--text-2); }
.users-list-item .ubadge { font-size: 11px; color: var(--text-3); background: var(--bg-hover); padding: 2px 6px; border-radius: 10px; } .users-list-item .ubadge { font-size: 11px; color: var(--text-3); background: var(--bg-hover); padding: 2px 6px; border-radius: 10px; }
/* ── Profile ───────────────────────────────────────────── */
.profile-avatar-section {
display: flex; align-items: center; gap: 20px;
margin-bottom: 28px; padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.profile-avatar {
width: 80px; height: 80px; border-radius: 50%;
background: var(--primary); display: flex;
align-items: center; justify-content: center;
font-size: 32px; font-weight: 700; color: #fff;
flex-shrink: 0; overflow: hidden; position: relative;
}
.profile-avatar img {
width: 100%; height: 100%; object-fit: cover;
position: absolute; inset: 0;
}
.profile-avatar-actions { display: flex; flex-direction: column; gap: 6px; }
.profile-username { font-size: 18px; font-weight: 600; color: var(--text-1); }
.btn-sm { padding: 6px 14px; font-size: 12px; }
.input-disabled { opacity: .5; cursor: not-allowed; }
.text-muted { font-size: 13px; color: var(--text-2); margin-bottom: 12px; }
.text-success { font-size: 13px; color: #34a853; margin-bottom: 12px; font-weight: 500; }
.totp-qr-wrapper {
display: flex; justify-content: center;
margin: 16px 0; padding: 16px;
background: #fff; border-radius: var(--radius);
width: fit-content;
}
.totp-qr-wrapper img { width: 200px; height: 200px; }
.totp-secret-row {
display: flex; align-items: center; gap: 8px;
background: var(--bg-app); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 8px 12px;
}
.totp-secret-row code {
font-family: monospace; font-size: 14px; color: var(--text-1);
word-break: break-all; flex: 1;
}
.profile-cal-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 0; border-bottom: 1px solid var(--border-light);
}
.profile-cal-dot {
width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0;
}
.profile-cal-name { font-size: 13px; color: var(--text-1); }
.profile-cal-account { font-size: 11px; color: var(--text-3); }
/* ── Toast ──────────────────────────────────────────────── */ /* ── Toast ──────────────────────────────────────────────── */
.toast { .toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);

View File

@@ -57,6 +57,10 @@
<label>Passwort</label> <label>Passwort</label>
<input type="password" id="login-password" required autocomplete="current-password" /> <input type="password" id="login-password" required autocomplete="current-password" />
</div> </div>
<div class="form-group hidden" id="login-totp-row">
<label>2FA-Code</label>
<input type="text" id="login-totp" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
</div>
<div id="login-error" class="form-error hidden"></div> <div id="login-error" class="form-error hidden"></div>
<button type="submit" class="btn btn-primary btn-full">Anmelden</button> <button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form> </form>
@@ -97,7 +101,20 @@
<button class="icon-btn" id="btn-settings" title="Einstellungen"> <button class="icon-btn" id="btn-settings" title="Einstellungen">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>
</button> </button>
<div class="user-menu-wrapper">
<div class="user-avatar" id="user-avatar" title="Benutzer"></div> <div class="user-avatar" id="user-avatar" title="Benutzer"></div>
<div class="user-dropdown hidden" id="user-dropdown">
<div class="dropdown-user" id="dropdown-username"></div>
<button class="dropdown-item" id="btn-profile">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
Profil
</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>
Abmelden
</button>
</div>
</div>
</div> </div>
</header> </header>
@@ -356,6 +373,114 @@
</div> </div>
</div> </div>
<!-- Profile Modal -->
<div id="modal-profile" class="modal-overlay hidden">
<div class="modal-card" style="max-width:540px">
<div class="modal-header">
<h3>Profil</h3>
<button class="icon-btn modal-close" data-modal="modal-profile">&times;</button>
</div>
<div class="modal-body">
<!-- Avatar Section -->
<div class="profile-avatar-section">
<div class="profile-avatar" id="profile-avatar">
<span id="profile-avatar-letter"></span>
<img id="profile-avatar-img" class="hidden" />
</div>
<div class="profile-avatar-actions">
<button class="btn btn-secondary btn-sm" id="profile-avatar-upload">Bild hochladen</button>
<button class="btn btn-ghost btn-sm hidden" id="profile-avatar-remove">Entfernen</button>
<input type="file" id="profile-avatar-input" accept="image/jpeg,image/png,image/webp" class="hidden" />
<div class="profile-username" id="profile-display-name"></div>
</div>
</div>
<!-- Account Info -->
<div class="settings-section">
<h4>Konto</h4>
<div class="form-group">
<label>Benutzername</label>
<input type="text" id="profile-username" disabled class="input-disabled" />
</div>
<div class="form-group">
<label>E-Mail</label>
<input type="email" id="profile-email" placeholder="Keine E-Mail hinterlegt" />
</div>
<button class="btn btn-primary btn-sm" id="profile-save-info">Speichern</button>
</div>
<!-- Password -->
<div class="settings-section">
<h4>Passwort ändern</h4>
<div class="form-group">
<label>Aktuelles Passwort</label>
<input type="password" id="profile-pw-current" autocomplete="current-password" />
</div>
<div class="form-group">
<label>Neues Passwort</label>
<input type="password" id="profile-pw-new" autocomplete="new-password" />
</div>
<div class="form-group">
<label>Neues Passwort wiederholen</label>
<input type="password" id="profile-pw-confirm" autocomplete="new-password" />
</div>
<button class="btn btn-primary btn-sm" id="profile-pw-save">Passwort ändern</button>
</div>
<!-- 2FA -->
<div class="settings-section">
<h4>Zwei-Faktor-Authentifizierung</h4>
<div id="2fa-status">
<div id="2fa-disabled-section">
<p class="text-muted">2FA ist deaktiviert. Schütze dein Konto mit einem Authenticator.</p>
<button class="btn btn-primary btn-sm" id="2fa-setup-btn">2FA einrichten</button>
</div>
<div id="2fa-setup-section" class="hidden">
<p class="text-muted">Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).</p>
<div class="totp-qr-wrapper">
<img id="2fa-qr-img" />
</div>
<div class="form-group">
<label>Oder gib diesen Schlüssel manuell ein</label>
<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>
</button>
</div>
</div>
<div class="form-group">
<label>Bestätigungscode eingeben</label>
<input type="text" id="2fa-verify-code" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
</div>
<button class="btn btn-primary btn-sm" id="2fa-enable-btn">Aktivieren</button>
<button class="btn btn-ghost btn-sm" id="2fa-cancel-btn">Abbrechen</button>
</div>
<div id="2fa-enabled-section" class="hidden">
<p class="text-success">2FA ist aktiviert.</p>
<div class="form-group">
<label>Passwort zum Deaktivieren</label>
<input type="password" id="2fa-disable-pw" autocomplete="current-password" />
</div>
<button class="btn btn-danger btn-sm" id="2fa-disable-btn">2FA deaktivieren</button>
</div>
</div>
</div>
<!-- Calendars -->
<div class="settings-section">
<h4>Meine Kalender</h4>
<div id="profile-calendars"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-modal="modal-profile">Schließen</button>
</div>
</div>
</div>
<!-- Toast --> <!-- Toast -->
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>

View File

@@ -19,7 +19,7 @@ async function request(method, path, body = null, formEncoded = false) {
const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr }); const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr });
if (res.status === 401) { if (res.status === 401 && !path.startsWith('/auth/login')) {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
window.location.reload(); window.location.reload();
@@ -35,14 +35,36 @@ async function request(method, path, body = null, formEncoded = false) {
return res.json(); return res.json();
} }
async function uploadRequest(path, formData) {
const token = localStorage.getItem('token');
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { method: 'POST', headers, body: formData });
if (res.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
return null;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Unbekannter Fehler' }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
if (res.status === 204) return null;
return res.json();
}
export const api = { export const api = {
get: (path) => request('GET', path), get: (path) => request('GET', path),
post: (path, body) => request('POST', path, body), post: (path, body) => request('POST', path, body),
put: (path, body) => request('PUT', path, body), put: (path, body) => request('PUT', path, body),
delete: (path) => request('DELETE', path), delete: (path) => request('DELETE', path),
upload: (path, form) => uploadRequest(path, form),
login: (username, password) => login: (username, password, totp_code = null) =>
request('POST', '/auth/token', { username, password }, true), request('POST', '/auth/login', { username, password, totp_code }),
setupRequired: () => request('GET', '/auth/setup-required'), setupRequired: () => request('GET', '/auth/setup-required'),
setup: (data) => request('POST', '/auth/setup', data), setup: (data) => request('POST', '/auth/setup', data),

View File

@@ -1,5 +1,5 @@
import { api } from './api.js'; import { api } from './api.js';
import { initCalendar, showToast } from './calendar.js'; import { initCalendar, showToast, openProfileModal } from './calendar.js';
// ── Bootstrap ───────────────────────────────────────────── // ── Bootstrap ─────────────────────────────────────────────
async function boot() { async function boot() {
@@ -57,15 +57,40 @@ async function launchApp() {
avatar.title = user.username; avatar.title = user.username;
} }
// Logout on avatar click (simple UX) // User dropdown menu
avatar.addEventListener('click', () => { const dropdown = document.getElementById('user-dropdown');
if (confirm('Abmelden?')) { document.getElementById('dropdown-username').textContent = user.username || 'Benutzer';
avatar.addEventListener('click', e => {
e.stopPropagation();
dropdown.classList.toggle('hidden');
});
document.addEventListener('click', e => {
if (!dropdown.contains(e.target) && e.target !== avatar) {
dropdown.classList.add('hidden');
}
});
document.getElementById('btn-profile').addEventListener('click', () => {
dropdown.classList.add('hidden');
openProfileModal();
});
document.getElementById('btn-logout').addEventListener('click', () => {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
window.location.reload(); window.location.reload();
}
}); });
// Load avatar image if available
try {
const me = await api.get('/auth/me');
if (me.has_avatar) {
avatar.innerHTML = `<img src="/api/profile/avatar?t=${Date.now()}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
}
} catch (_) {}
await initCalendar(); await initCalendar();
} }
@@ -106,22 +131,30 @@ function bindSetupForm() {
// ── Login Form ──────────────────────────────────────────── // ── Login Form ────────────────────────────────────────────
function bindLoginForm() { function bindLoginForm() {
const totpRow = document.getElementById('login-totp-row');
document.getElementById('login-form').addEventListener('submit', async e => { document.getElementById('login-form').addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const username = document.getElementById('login-username').value.trim(); const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value; const password = document.getElementById('login-password').value;
const totpCode = document.getElementById('login-totp')?.value.trim() || null;
const errEl = document.getElementById('login-error'); const errEl = document.getElementById('login-error');
errEl.classList.add('hidden'); errEl.classList.add('hidden');
try { try {
const res = await api.login(username, password); const res = await api.login(username, password, totpCode);
localStorage.setItem('token', res.access_token); localStorage.setItem('token', res.access_token);
localStorage.setItem('user', JSON.stringify(res.user)); localStorage.setItem('user', JSON.stringify(res.user));
await launchApp(); await launchApp();
} catch (err) { } catch (err) {
if (err.message === '2fa_required') {
totpRow.classList.remove('hidden');
document.getElementById('login-totp').focus();
} else {
errEl.textContent = err.message; errEl.textContent = err.message;
errEl.classList.remove('hidden'); errEl.classList.remove('hidden');
} }
}
}); });
} }

View File

@@ -40,6 +40,7 @@ export async function initCalendar() {
bindEventModal(); bindEventModal();
bindAccountModal(); bindAccountModal();
bindSettingsModal(); bindSettingsModal();
bindProfileModal();
} }
// ── Data fetching ───────────────────────────────────────── // ── Data fetching ─────────────────────────────────────────
@@ -231,7 +232,10 @@ function renderCalendarList() {
acc.calendars.map(cal => acc.calendars.map(cal =>
`<div class="cal-item" data-cal-id="${cal.id}"> `<div class="cal-item" data-cal-id="${cal.id}">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" /> <input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" />
<div class="cal-item-dot" style="background:${cal.color}"></div> <div class="cal-item-dot-wrapper">
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" title="Farbe ändern"></div>
<input type="color" class="cal-color-input" data-cal-id="${cal.id}" value="${cal.color || '#4285f4'}" />
</div>
<span class="cal-item-name">${escHtml(cal.name)}</span> <span class="cal-item-name">${escHtml(cal.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-acc-id="${acc.id}" title="Konto entfernen"> <button class="icon-btn mini-btn cal-item-remove" data-acc-id="${acc.id}" title="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> <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>
@@ -256,6 +260,29 @@ function renderCalendarList() {
}); });
}); });
container.querySelectorAll('.cal-item-dot').forEach(dot => {
dot.addEventListener('click', e => {
e.stopPropagation();
const colorInput = dot.parentElement.querySelector('.cal-color-input');
colorInput.click();
});
});
container.querySelectorAll('.cal-color-input').forEach(input => {
input.addEventListener('change', async e => {
const calId = parseInt(e.target.dataset.calId);
const color = e.target.value;
await api.put(`/caldav/calendars/${calId}`, { color });
for (const acc of state.accounts) {
for (const cal of acc.calendars) {
if (cal.id === calId) cal.color = color;
}
}
renderCalendarList();
fetchAndRender();
});
});
container.querySelectorAll('.cal-item-remove').forEach(btn => { container.querySelectorAll('.cal-item-remove').forEach(btn => {
btn.addEventListener('click', async e => { btn.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation();
@@ -675,6 +702,191 @@ function bindSettingsModal() {
}; };
} }
// ── Profile Modal ─────────────────────────────────────────
export function openProfileModal() {
const user = JSON.parse(localStorage.getItem('user') || '{}');
// Username & email
document.getElementById('profile-username').value = user.username || '';
document.getElementById('profile-display-name').textContent = user.username || '';
// Load fresh profile data
api.get('/profile/').then(profile => {
document.getElementById('profile-email').value = profile.email || '';
// Avatar
const letter = document.getElementById('profile-avatar-letter');
const img = document.getElementById('profile-avatar-img');
const removeBtn = document.getElementById('profile-avatar-remove');
if (profile.has_avatar) {
img.src = `/api/profile/avatar?t=${Date.now()}`;
img.classList.remove('hidden');
letter.classList.add('hidden');
removeBtn.classList.remove('hidden');
} else {
img.classList.add('hidden');
letter.classList.remove('hidden');
letter.textContent = (user.username || '?')[0].toUpperCase();
removeBtn.classList.add('hidden');
}
// 2FA status
document.getElementById('2fa-disabled-section').classList.toggle('hidden', profile.totp_enabled);
document.getElementById('2fa-setup-section').classList.add('hidden');
document.getElementById('2fa-enabled-section').classList.toggle('hidden', !profile.totp_enabled);
}).catch(() => {});
// Clear password fields
document.getElementById('profile-pw-current').value = '';
document.getElementById('profile-pw-new').value = '';
document.getElementById('profile-pw-confirm').value = '';
document.getElementById('2fa-disable-pw').value = '';
document.getElementById('2fa-verify-code').value = '';
// Load calendars
renderProfileCalendars();
openModal('modal-profile');
}
function renderProfileCalendars() {
const container = document.getElementById('profile-calendars');
if (!state.accounts.length) {
container.innerHTML = '<p class="text-muted">Keine CalDAV-Konten verbunden.</p>';
return;
}
const html = state.accounts.map(acc =>
acc.calendars.map(cal =>
`<div class="profile-cal-item">
<div class="profile-cal-dot" style="background:${cal.color}"></div>
<div>
<div class="profile-cal-name">${escHtml(cal.name)}</div>
<div class="profile-cal-account">${escHtml(acc.name)}</div>
</div>
</div>`
).join('')
).join('');
container.innerHTML = html;
}
function bindProfileModal() {
// Save profile info (email)
document.getElementById('profile-save-info').onclick = async () => {
const email = document.getElementById('profile-email').value.trim();
try {
await api.put('/profile/', { email: email || null });
showToast('Profil gespeichert');
} catch (e) { showToast(e.message, true); }
};
// Avatar upload
document.getElementById('profile-avatar-upload').onclick = () => {
document.getElementById('profile-avatar-input').click();
};
document.getElementById('profile-avatar-input').onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
try {
await api.upload('/profile/avatar', form);
showToast('Profilbild hochgeladen');
// Update display
const img = document.getElementById('profile-avatar-img');
img.src = `/api/profile/avatar?t=${Date.now()}`;
img.classList.remove('hidden');
document.getElementById('profile-avatar-letter').classList.add('hidden');
document.getElementById('profile-avatar-remove').classList.remove('hidden');
// Update topbar avatar
updateTopbarAvatar(true);
} catch (err) { showToast(err.message, true); }
e.target.value = '';
};
document.getElementById('profile-avatar-remove').onclick = async () => {
try {
await api.delete('/profile/avatar');
showToast('Profilbild entfernt');
document.getElementById('profile-avatar-img').classList.add('hidden');
document.getElementById('profile-avatar-letter').classList.remove('hidden');
document.getElementById('profile-avatar-remove').classList.add('hidden');
updateTopbarAvatar(false);
} catch (e) { showToast(e.message, true); }
};
// Password change
document.getElementById('profile-pw-save').onclick = async () => {
const current = document.getElementById('profile-pw-current').value;
const newPw = document.getElementById('profile-pw-new').value;
const confirm = document.getElementById('profile-pw-confirm').value;
if (!current || !newPw) { showToast('Bitte alle Felder ausfüllen', true); return; }
if (newPw !== confirm) { showToast('Passwörter stimmen nicht überein', true); return; }
if (newPw.length < 6) { showToast('Mindestens 6 Zeichen', true); return; }
try {
await api.post('/profile/password', { current_password: current, new_password: newPw });
showToast('Passwort geändert');
document.getElementById('profile-pw-current').value = '';
document.getElementById('profile-pw-new').value = '';
document.getElementById('profile-pw-confirm').value = '';
} catch (e) { showToast(e.message, true); }
};
// 2FA Setup
document.getElementById('2fa-setup-btn').onclick = async () => {
try {
const res = await api.post('/profile/2fa/setup', {});
document.getElementById('2fa-qr-img').src = res.qr_code;
document.getElementById('2fa-secret-code').textContent = res.secret;
document.getElementById('2fa-disabled-section').classList.add('hidden');
document.getElementById('2fa-setup-section').classList.remove('hidden');
} catch (e) { showToast(e.message, true); }
};
document.getElementById('2fa-copy-secret').onclick = () => {
const secret = document.getElementById('2fa-secret-code').textContent;
navigator.clipboard.writeText(secret).then(() => showToast('Schlüssel kopiert'));
};
document.getElementById('2fa-enable-btn').onclick = async () => {
const code = document.getElementById('2fa-verify-code').value.trim();
if (!code || code.length !== 6) { showToast('Bitte 6-stelligen Code eingeben', true); return; }
try {
await api.post('/profile/2fa/enable', { code });
showToast('2FA aktiviert');
document.getElementById('2fa-setup-section').classList.add('hidden');
document.getElementById('2fa-enabled-section').classList.remove('hidden');
} catch (e) { showToast(e.message, true); }
};
document.getElementById('2fa-cancel-btn').onclick = () => {
document.getElementById('2fa-setup-section').classList.add('hidden');
document.getElementById('2fa-disabled-section').classList.remove('hidden');
};
document.getElementById('2fa-disable-btn').onclick = async () => {
const pw = document.getElementById('2fa-disable-pw').value;
if (!pw) { showToast('Bitte Passwort eingeben', true); return; }
try {
await api.post('/profile/2fa/disable', { password: pw });
showToast('2FA deaktiviert');
document.getElementById('2fa-enabled-section').classList.add('hidden');
document.getElementById('2fa-disabled-section').classList.remove('hidden');
document.getElementById('2fa-disable-pw').value = '';
} catch (e) { showToast(e.message, true); }
};
}
function updateTopbarAvatar(hasAvatar) {
const avatar = document.getElementById('user-avatar');
if (hasAvatar) {
avatar.innerHTML = `<img src="/api/profile/avatar?t=${Date.now()}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
} else {
const user = JSON.parse(localStorage.getItem('user') || '{}');
avatar.textContent = (user.username || '?')[0].toUpperCase();
}
}
// ── Modal helpers ───────────────────────────────────────── // ── Modal helpers ─────────────────────────────────────────
function openModal(id) { function openModal(id) {
document.getElementById(id).classList.remove('hidden'); document.getElementById(id).classList.remove('hidden');

View File

@@ -2,8 +2,11 @@ fastapi==0.115.0
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
sqlalchemy==2.0.35 sqlalchemy==2.0.35
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 bcrypt==4.2.1
python-multipart==0.0.12 python-multipart==0.0.12
caldav==1.3.9 caldav==1.3.9
icalendar==5.0.12 icalendar==5.0.12
requests==2.32.3 requests==2.32.3
pyotp==2.9.0
qrcode[pil]==8.0
Pillow==11.0.0