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:
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
203
backend/routers/profile_router.py
Normal file
203
backend/routers/profile_router.py
Normal 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}
|
||||||
@@ -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%);
|
||||||
|
|||||||
@@ -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">×</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user