diff --git a/backend/main.py b/backend/main.py index 729459a..eb8e3c7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles sys.path.insert(0, str(Path(__file__).parent)) 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) @@ -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(caldav_router.router, prefix="/api/caldav", tags=["caldav"]) 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" app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") diff --git a/backend/models.py b/backend/models.py index 4e119fa..9107fb8 100644 --- a/backend/models.py +++ b/backend/models.py @@ -11,6 +11,9 @@ class User(Base): email = Column(String(100), unique=True, nullable=True) password_hash = Column(String(255), nullable=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( "CalDAVAccount", back_populates="user", cascade="all, delete-orphan" diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py index ccd26dc..4376f94 100644 --- a/backend/routers/auth_router.py +++ b/backend/routers/auth_router.py @@ -1,5 +1,6 @@ from typing import Optional +import pyotp from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel @@ -18,6 +19,12 @@ class SetupRequest(BaseModel): email: Optional[str] = None +class LoginRequest(BaseModel): + username: str + password: str + totp_code: Optional[str] = None + + def _user_dict(user: models.User) -> dict: return {"id": user.id, "username": user.username, "is_admin": user.is_admin} @@ -59,6 +66,37 @@ def login( detail="Incorrect username or password", 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}) 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, "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, } diff --git a/backend/routers/profile_router.py b/backend/routers/profile_router.py new file mode 100644 index 0000000..10a1726 --- /dev/null +++ b/backend/routers/profile_router.py @@ -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} diff --git a/frontend/css/app.css b/frontend/css/app.css index 20a94d7..c1d9e48 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -212,6 +212,7 @@ a { color: var(--primary); text-decoration: none; } .view-btn:hover { background: var(--bg-hover); color: var(--text-1); } .view-btn.active { background: var(--primary-dim); color: var(--primary); } +.user-menu-wrapper { position: relative; } .user-avatar { width: 34px; height: 34px; border-radius: 50%; background: var(--primary); @@ -219,6 +220,26 @@ a { color: var(--primary); text-decoration: none; } font-weight: 600; font-size: 14px; color: #fff; 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 ─────────────────────────────────────────────── */ #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-inner { padding-bottom: 24px; } .main-view { flex: 1; overflow: auto; display: flex; flex-direction: column; } +#view-container { flex: 1; display: flex; flex-direction: column; } /* ── Mini Calendar ──────────────────────────────────────── */ .mini-cal { padding: 12px 16px; } @@ -285,8 +307,15 @@ a { color: var(--primary); text-decoration: none; } margin-right: 12px; } .cal-item:hover { background: var(--bg-hover); } +.cal-item-dot-wrapper { position: relative; flex-shrink: 0; } .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-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 .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 { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); diff --git a/frontend/index.html b/frontend/index.html index 58268b2..180e41f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -57,6 +57,10 @@ + @@ -97,7 +101,20 @@ -
+
+
+ +
@@ -356,6 +373,114 @@ + + + diff --git a/frontend/js/api.js b/frontend/js/api.js index 4b0828c..c470bad 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -19,7 +19,7 @@ async function request(method, path, body = null, formEncoded = false) { 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('user'); window.location.reload(); @@ -35,14 +35,36 @@ async function request(method, path, body = null, formEncoded = false) { 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 = { get: (path) => request('GET', path), post: (path, body) => request('POST', path, body), put: (path, body) => request('PUT', path, body), delete: (path) => request('DELETE', path), + upload: (path, form) => uploadRequest(path, form), - login: (username, password) => - request('POST', '/auth/token', { username, password }, true), + login: (username, password, totp_code = null) => + request('POST', '/auth/login', { username, password, totp_code }), setupRequired: () => request('GET', '/auth/setup-required'), setup: (data) => request('POST', '/auth/setup', data), diff --git a/frontend/js/app.js b/frontend/js/app.js index 74273ee..aef791a 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,5 +1,5 @@ import { api } from './api.js'; -import { initCalendar, showToast } from './calendar.js'; +import { initCalendar, showToast, openProfileModal } from './calendar.js'; // ── Bootstrap ───────────────────────────────────────────── async function boot() { @@ -57,15 +57,40 @@ async function launchApp() { avatar.title = user.username; } - // Logout on avatar click (simple UX) - avatar.addEventListener('click', () => { - if (confirm('Abmelden?')) { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - window.location.reload(); + // User dropdown menu + const dropdown = document.getElementById('user-dropdown'); + 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('user'); + window.location.reload(); + }); + + // Load avatar image if available + try { + const me = await api.get('/auth/me'); + if (me.has_avatar) { + avatar.innerHTML = ``; + } + } catch (_) {} + await initCalendar(); } @@ -106,21 +131,29 @@ function bindSetupForm() { // ── Login Form ──────────────────────────────────────────── function bindLoginForm() { + const totpRow = document.getElementById('login-totp-row'); + document.getElementById('login-form').addEventListener('submit', async e => { e.preventDefault(); const username = document.getElementById('login-username').value.trim(); const password = document.getElementById('login-password').value; + const totpCode = document.getElementById('login-totp')?.value.trim() || null; const errEl = document.getElementById('login-error'); errEl.classList.add('hidden'); try { - const res = await api.login(username, password); + const res = await api.login(username, password, totpCode); localStorage.setItem('token', res.access_token); localStorage.setItem('user', JSON.stringify(res.user)); await launchApp(); } catch (err) { - errEl.textContent = err.message; - errEl.classList.remove('hidden'); + if (err.message === '2fa_required') { + totpRow.classList.remove('hidden'); + document.getElementById('login-totp').focus(); + } else { + errEl.textContent = err.message; + errEl.classList.remove('hidden'); + } } }); } diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 465e455..c7b5ec0 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -40,6 +40,7 @@ export async function initCalendar() { bindEventModal(); bindAccountModal(); bindSettingsModal(); + bindProfileModal(); } // ── Data fetching ───────────────────────────────────────── @@ -231,7 +232,10 @@ function renderCalendarList() { acc.calendars.map(cal => `
-
+
+
+ +
${escHtml(cal.name)}