diff --git a/backend/ical_io.py b/backend/ical_io.py new file mode 100644 index 0000000..905c471 --- /dev/null +++ b/backend/ical_io.py @@ -0,0 +1,205 @@ +"""iCal (.ics) import/export for local calendars. + +Reuses the already-installed ``icalendar`` library. The parser produces dicts +matching the LocalEvent storage shape (ISO strings, comma-separated EXDATE); +the generator emits a VCALENDAR with ORGANIZER, RRULE, etc. +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import date, datetime, timedelta, timezone + +from icalendar import Calendar, Event, vCalAddress, vRecur, vText + +logger = logging.getLogger(__name__) + + +def _rrule_to_str(component) -> str | None: + prop = component.get("RRULE") + if not prop: + return None + return prop.to_ical().decode("utf-8") + + +def _exdate_to_csv(component) -> str | None: + """Collect EXDATE values as comma-separated YYYYMMDD strings.""" + exdate = component.get("EXDATE") + if not exdate: + return None + items = exdate if isinstance(exdate, list) else [exdate] + out = [] + for ex in items: + dts = getattr(ex, "dts", None) or [] + for d in dts: + val = d.dt + if isinstance(val, datetime): + out.append(val.strftime("%Y%m%d")) + elif isinstance(val, date): + out.append(val.strftime("%Y%m%d")) + return ",".join(out) if out else None + + +def _organizer_name(component) -> str | None: + org = component.get("ORGANIZER") + if not org: + return None + # CN parameter holds the display name; fall back to the mailto address. + try: + cn = org.params.get("CN") + if cn: + return str(cn) + except Exception: + pass + raw = str(org) + if raw.lower().startswith("mailto:"): + return raw[7:] + return raw or None + + +def parse_ics(raw: bytes) -> dict: + """Parse .ics bytes into {"events": [dict, ...], "errors": [str, ...]}. + + Raises ValueError if the payload is not a parseable calendar at all. + """ + try: + cal = Calendar.from_ical(raw) + except Exception as e: + raise ValueError(f"Datei ist kein gültiges iCal-Format: {e}") from e + + events = [] + errors = [] + for component in cal.walk(): + if component.name != "VEVENT": + continue + try: + uid = str(component.get("UID") or uuid.uuid4()) + title = str(component.get("SUMMARY", "") or "") + location = str(component.get("LOCATION", "") or "") or None + description = str(component.get("DESCRIPTION", "") or "") or None + + dtstart_prop = component.get("DTSTART") + if dtstart_prop is None: + errors.append(f"VEVENT {uid}: kein DTSTART, übersprungen") + continue + dtstart = dtstart_prop.dt + dtend_prop = component.get("DTEND") + duration_prop = component.get("DURATION") + + all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime) + if all_day: + if dtend_prop: + dtend = dtend_prop.dt + elif duration_prop: + dtend = dtstart + duration_prop.dt + else: + dtend = dtstart + timedelta(days=1) + start_str = dtstart.isoformat() + end_str = (dtend.isoformat() if isinstance(dtend, date) + else (dtstart + timedelta(days=1)).isoformat()) + else: + if dtstart.tzinfo is None: + dtstart = dtstart.replace(tzinfo=timezone.utc) + if dtend_prop: + dtend = dtend_prop.dt + if isinstance(dtend, date) and not isinstance(dtend, datetime): + dtend = datetime.combine(dtend, datetime.min.time(), tzinfo=timezone.utc) + elif dtend.tzinfo is None: + dtend = dtend.replace(tzinfo=timezone.utc) + elif duration_prop: + dtend = dtstart + duration_prop.dt + else: + dtend = dtstart + timedelta(hours=1) + start_str = dtstart.isoformat() + end_str = dtend.isoformat() + + events.append({ + "uid": uid, + "title": title, + "start": start_str, + "end": end_str, + "all_day": all_day, + "location": location, + "description": description, + "rrule": _rrule_to_str(component), + "exdate": _exdate_to_csv(component), + "organizer": _organizer_name(component), + }) + except Exception as exc: + logger.warning("Skipping malformed VEVENT: %s", exc) + errors.append(f"Fehlerhafter Eintrag übersprungen: {exc}") + return {"events": events, "errors": errors} + + +def _rrule_str_to_vrecur(rrule_str: str) -> vRecur: + params = {} + for part in rrule_str.split(";"): + if "=" not in part: + continue + key, val = part.split("=", 1) + params[key] = val.split(",") if "," in val else val + return vRecur(params) + + +def _parse_iso(s: str) -> datetime: + s = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def build_ics(calendar, events, *, name_cache: dict | None = None) -> str: + """Build a VCALENDAR string for a local calendar and its events.""" + cal = Calendar() + cal.add("prodid", "-//Calendarr//EN") + cal.add("version", "2.0") + cal.add("x-wr-calname", calendar.name) + + for ev in events: + item = Event() + item.add("uid", ev.uid) + item.add("summary", ev.title or "") + item.add("dtstamp", datetime.now(timezone.utc)) + + if ev.all_day: + try: + start = date.fromisoformat(ev.start[:10]) + end = date.fromisoformat(ev.end[:10]) + except ValueError: + continue + if end <= start: + end = start + timedelta(days=1) + item.add("dtstart", start) + item.add("dtend", end) + else: + try: + item.add("dtstart", _parse_iso(ev.start)) + item.add("dtend", _parse_iso(ev.end)) + except ValueError: + continue + + if ev.location: + item.add("location", ev.location) + if ev.description: + item.add("description", ev.description) + if ev.color: + item.add("x-calendarr-color", ev.color) + if ev.rrule: + item.add("rrule", _rrule_str_to_vrecur(ev.rrule)) + + # ORGANIZER from the creator (local user or imported name). + organizer_name = None + if getattr(ev, "creator_id", None) and name_cache: + organizer_name = name_cache.get(ev.creator_id) + if not organizer_name: + organizer_name = getattr(ev, "creator_name_external", None) + if organizer_name: + organizer = vCalAddress("mailto:noreply@calendarr.local") + organizer.params["CN"] = vText(organizer_name.replace('"', "")) + item.add("organizer", organizer) + + cal.add_component(item) + + return cal.to_ical().decode("utf-8") diff --git a/backend/local_events_util.py b/backend/local_events_util.py new file mode 100644 index 0000000..edca593 --- /dev/null +++ b/backend/local_events_util.py @@ -0,0 +1,213 @@ +"""Shared builders for local-event API dicts. + +Every local event returned by the API (the local router, the unified event +merge in caldav_router, and the group combined view) must look identical and +carry the additive collaboration fields: ``creator``, ``private``, ``type``, +and — in the group view — ``owner`` and ``is_group_event``. + +Centralising this avoids the three near-duplicate dict constructions that used +to live in caldav_router.py. +""" + +import logging +from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone +from typing import Optional + +from dateutil.rrule import rrulestr +from sqlalchemy.orm import Session + +import models + +logger = logging.getLogger(__name__) + + +def resolve_creator(ev: models.LocalEvent, *, name_cache: Optional[dict] = None) -> Optional[dict]: + """Build the ``creator`` payload for an event. + + Returns ``{"id": int, "display_name": username}`` for a local creator, + ``{"id": None, "display_name": " (importiert)"}`` for an imported + event, or ``None`` when no creator info exists (legacy events). + + ``name_cache`` maps user_id -> username to avoid per-event DB lookups; the + creator relationship is used as a fallback. + """ + if ev.creator_id: + display = None + if name_cache is not None: + display = name_cache.get(ev.creator_id) + if display is None and ev.creator is not None: + display = ev.creator.display_name or ev.creator.username + if display is not None: + return {"id": ev.creator_id, "display_name": display} + if ev.creator_name_external: + return {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"} + return None + + +def private_visibility_for(db: Session, user_id: int) -> str: + """A user's chosen visibility for their private events ('hidden' | 'busy').""" + s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first() + return (s.private_event_visibility if s else None) or "busy" + + +# Only these fields survive 'busy' anonymisation — a whitelist, so no content +# field (title/location/description/creator/calendar name/recurrence) can leak. +_BUSY_KEEP = { + "id", "url", "start", "end", "allDay", "calendar_id", "calendarColor", + "source", "type", "owner", "is_group_event", "display_color", +} + + +def mask_busy_event(event: dict) -> dict: + """Anonymise a private event for 'busy' visibility: keep only timing / + identity / render fields, drop ALL content.""" + masked = {k: event[k] for k in _BUSY_KEEP if k in event} + masked["title"] = "Beschäftigt" + masked["location"] = "" + masked["description"] = "" + masked["calendar_name"] = "" + masked["creator"] = None + masked["color"] = None + masked["rrule"] = None + masked["exdate"] = None + masked["private"] = True + return masked + + +def apply_event_privacy( + event: dict, *, owner_id, is_private: bool, requester_id: int, visibility: str +) -> Optional[dict]: + """Enforce another user's private-event visibility on a built event dict. + + Returns the event unchanged for the requester's own events or non-private + events, ``None`` when the owner chose 'hidden', or a busy-masked copy when + the owner chose 'busy'. Used by BOTH the merge read and the combined view so + the privacy rule can never drift between them. + """ + if not is_private or owner_id == requester_id: + return event + if visibility == "hidden": + return None + return mask_busy_event(event) + + +def build_local_event_dict( + ev: models.LocalEvent, + cal: models.LocalCalendar, + *, + start: Optional[str] = None, + end: Optional[str] = None, + all_day: Optional[bool] = None, + rrule: Optional[str] = ..., + creator: Optional[dict] = None, + owner: Optional[dict] = None, + is_group_event: bool = False, +) -> dict: + """Build the unified dict for a single local event (or occurrence). + + ``start``/``end``/``all_day`` override the stored values (used when emitting + an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set + by the group combined view. + """ + d = { + "id": ev.uid, + "url": f"local://{ev.uid}", + "title": ev.title, + "start": ev.start if start is None else start, + "end": ev.end if end is None else end, + "allDay": ev.all_day if all_day is None else all_day, + "location": ev.location or "", + "description": ev.description or "", + "color": ev.color, + "rrule": ev.rrule if rrule is ... else rrule, + "exdate": ev.exdate, + "calendar_id": f"local-{cal.id}", + "calendar_name": cal.name, + "calendarColor": cal.color, + "source": "local", + "type": "local", + "creator": creator, + "private": bool(ev.is_private), + "reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()], + } + if owner is not None: + d["owner"] = owner + if is_group_event: + d["is_group_event"] = True + return d + + +def expand_recurring_local( + ev: models.LocalEvent, + local_cal: models.LocalCalendar, + range_start, + range_end, + *, + creator: Optional[dict] = None, + owner: Optional[dict] = None, + is_group_event: bool = False, +) -> list: + """Expand a recurring LocalEvent into individual occurrences in the range.""" + results = [] + excluded = set() + if ev.exdate: + for d in ev.exdate.split(","): + d = d.strip() + if d: + excluded.add(d) + try: + ev_start_str = ev.start.replace("Z", "+00:00") + ev_end_str = ev.end.replace("Z", "+00:00") + + if ev.all_day: + ev_start = dt_date.fromisoformat(ev_start_str[:10]) + ev_end = dt_date.fromisoformat(ev_end_str[:10]) + duration = ev_end - ev_start + rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time())) + r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time()) + r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time()) + occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) + for occ in occurrences: + occ_start = occ.date() + occ_key = occ_start.strftime("%Y%m%d") + if occ_key in excluded: + continue + occ_end = occ_start + duration + results.append(build_local_event_dict( + ev, local_cal, + start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True, + creator=creator, owner=owner, is_group_event=is_group_event, + )) + else: + ev_start = dt_datetime.fromisoformat(ev_start_str) + ev_end = dt_datetime.fromisoformat(ev_end_str) + if ev_start.tzinfo is None: + ev_start = ev_start.replace(tzinfo=dt_timezone.utc) + if ev_end.tzinfo is None: + ev_end = ev_end.replace(tzinfo=dt_timezone.utc) + duration = ev_end - ev_start + rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start) + r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc) + r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc) + if r_start.tzinfo is None: + r_start = r_start.replace(tzinfo=dt_timezone.utc) + if r_end.tzinfo is None: + r_end = r_end.replace(tzinfo=dt_timezone.utc) + occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) + for occ in occurrences: + occ_key = occ.strftime("%Y%m%d") + if occ_key in excluded: + continue + occ_end = occ + duration + results.append(build_local_event_dict( + ev, local_cal, + start=occ.isoformat(), end=occ_end.isoformat(), all_day=False, + creator=creator, owner=owner, is_group_event=is_group_event, + )) + except Exception as exc: + logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) + # Fall back to a single event. + results.append(build_local_event_dict( + ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event, + )) + return results diff --git a/backend/main.py b/backend/main.py index dade943..b931917 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,15 +4,20 @@ import sys from pathlib import Path import uvicorn -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from sqlalchemy import text +# How long the browser may keep static assets before revalidating. +STATIC_MAX_AGE_SECONDS = 2 * 60 * 60 # 2 hours +NO_CACHE = "no-cache, no-store, must-revalidate" +STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate" + sys.path.insert(0, str(Path(__file__).parent)) from database import Base, engine -from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router +from routers import auth_router, caldav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router logging.basicConfig(level=logging.INFO) @@ -97,6 +102,20 @@ def _migrate(): except Exception: pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN reminders TEXT")) + conn.commit() + logging.info("Migration: added reminders to local_events") + except Exception: + pass + + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN default_reminder_minutes INTEGER")) + conn.commit() + logging.info("Migration: added default_reminder_minutes to user_settings") + except Exception: + pass + try: conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'")) conn.commit() @@ -109,16 +128,124 @@ def _migrate(): except Exception: pass + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN text_color VARCHAR(7)")) + conn.commit() + except Exception: + pass + + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN line_color VARCHAR(7)")) + conn.commit() + except Exception: + pass + + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN bg_color VARCHAR(7)")) + conn.commit() + except Exception: + pass + + # ── Collaboration features (sharing, groups, creator, private) ── + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN private_event_visibility VARCHAR(10) DEFAULT 'busy'")) + conn.commit() + logging.info("Migration: added private_event_visibility to user_settings") + except Exception: + pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_id INTEGER")) + conn.commit() + logging.info("Migration: added creator_id to local_events") + except Exception: + pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_name_external TEXT")) + conn.commit() + logging.info("Migration: added creator_name_external to local_events") + except Exception: + pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN is_private BOOLEAN DEFAULT 0")) + conn.commit() + logging.info("Migration: added is_private to local_events") + except Exception: + pass + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN group_visible_calendar_id INTEGER")) + conn.commit() + logging.info("Migration: added group_visible_calendar_id to user_settings") + except Exception: + pass + try: + conn.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR(100)")) + conn.commit() + logging.info("Migration: added display_name to users") + except Exception: + pass + # Backfill display_name from username for existing rows (only where empty). + try: + conn.execute(text("UPDATE users SET display_name = username WHERE display_name IS NULL OR display_name = ''")) + conn.commit() + except Exception: + pass + try: + conn.execute(text("ALTER TABLE groups ADD COLUMN icon VARCHAR(16)")) + conn.commit() + logging.info("Migration: added icon to groups") + except Exception: + pass + try: + conn.execute(text("ALTER TABLE group_members ADD COLUMN color VARCHAR(7)")) + conn.commit() + logging.info("Migration: added color to group_members") + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) + +@app.middleware("http") +async def add_cache_headers(request: Request, call_next): + """Force ≤ 2h browser cache for static assets and disable cache for the + entry HTML / SW / version file. API responses are left alone (handlers + decide their own caching).""" + response = await call_next(request) + path = request.url.path + + # Never cache: entry HTML, manifest, service worker, version marker + if ( + path in ("/", "/index.html", "/manifest.json", "/sw.js") + or path == "/static/js/version.js" + ): + response.headers["Cache-Control"] = NO_CACHE + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + # JS/CSS must revalidate on every load so a deploy takes effect on the next + # reload (returns a cheap 304 when unchanged). Without this, a fresh + # no-cache index.html could pair with stale 2h-cached scripts. + elif path.startswith("/static/js/") or path.startswith("/static/css/"): + response.headers["Cache-Control"] = NO_CACHE + # 2h cache for the rest of the frontend (icons, fonts, images, …) + elif path.startswith("/static/") or path.startswith("/icons/"): + response.headers["Cache-Control"] = STATIC_CACHE + # SPA fallback (everything else that isn't an API route) returns HTML; + # don't let the browser cache that either. + elif not path.startswith("/api/"): + response.headers["Cache-Control"] = NO_CACHE + + return response + + 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"]) app.include_router(local_router.router, prefix="/api/local", tags=["local"]) +app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"]) app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"]) app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"]) diff --git a/backend/models.py b/backend/models.py index 1892076..e09d976 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint from sqlalchemy.orm import relationship from database import Base @@ -7,7 +7,10 @@ class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) + # Login name: always lowercase, unique, used for authentication. username = Column(String(50), unique=True, nullable=False) + # Human-facing name with original casing; editable. Falls back to username. + display_name = Column(String(100), nullable=True) email = Column(String(100), unique=True, nullable=True) password_hash = Column(String(255), nullable=False) is_admin = Column(Boolean, default=False) @@ -34,6 +37,11 @@ class User(Base): "HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan" ) + @property + def display(self) -> str: + """The name to show users: display_name if set, else the login name.""" + return self.display_name or self.username + class CalDAVAccount(Base): __tablename__ = "caldav_accounts" @@ -84,6 +92,18 @@ class UserSettings(Base): language = Column(String(5), default="de") month_divider_color = Column(String(7), default="#7090c0") month_label_color = Column(String(7), default="#7090c0") + text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast) + line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast) + bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default) + # How this user's private events appear to other group members: + # 'hidden' = invisible, 'busy' = anonymous busy block (default). + private_event_visibility = Column(String(10), default="busy") + # The single local calendar this user shares into all their groups + # (combined view shows only this calendar per member). NULL = share nothing. + group_visible_calendar_id = Column(Integer, nullable=True) + # Default reminder in minutes-before-start applied to all events client-side + # (0 = at start time). NULL = no default reminder. + default_reminder_minutes = Column(Integer, nullable=True) user = relationship("User", back_populates="settings") @@ -116,8 +136,17 @@ class LocalEvent(Base): color = Column(String(7), nullable=True) rrule = Column(Text, nullable=True) exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude + # Comma-separated minutes-before-start for reminders, e.g. "10,60" (0 = at start). + reminders = Column(Text, nullable=True) + # Creator: set server-side from the auth token on create, never from the client. + creator_id = Column(Integer, ForeignKey("users.id"), nullable=True) + # For imported events without a local user (from the .ics ORGANIZER field). + creator_name_external = Column(Text, nullable=True) + # Private events are filtered for other group members per their visibility setting. + is_private = Column(Boolean, default=False) calendar = relationship("LocalCalendar", back_populates="events") + creator = relationship("User") class ICalSubscription(Base): @@ -216,3 +245,76 @@ class HomeAssistantCalendar(Base): sidebar_hidden = Column(Boolean, default=False) account = relationship("HomeAssistantAccount", back_populates="calendars") + + +# ── Collaboration: sharing & groups (local calendars only) ──────────────── + + +class CalendarShare(Base): + """A local calendar shared with another Calendarr user.""" + + __tablename__ = "calendar_shares" + __table_args__ = ( + UniqueConstraint("calendar_id", "user_id", name="uq_calendar_share"), + ) + + id = Column(Integer, primary_key=True, index=True) + calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + permission = Column(String(20), default="read") # 'read' | 'read_write' + created_at = Column(String(50), nullable=True) # ISO 8601 + + calendar = relationship("LocalCalendar") + user = relationship("User") + + +class Group(Base): + __tablename__ = "groups" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + icon = Column(String(16), nullable=True) # emoji shown for the group + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(String(50), nullable=True) # ISO 8601 + + members = relationship( + "GroupMember", back_populates="group", cascade="all, delete-orphan" + ) + group_calendar = relationship( + "GroupCalendar", back_populates="group", uselist=False, + cascade="all, delete-orphan", + ) + + +class GroupMember(Base): + __tablename__ = "group_members" + __table_args__ = ( + UniqueConstraint("group_id", "user_id", name="uq_group_member"), + ) + + id = Column(Integer, primary_key=True, index=True) + group_id = Column(Integer, ForeignKey("groups.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + role = Column(String(10), default="member") # 'owner' | 'member' + color = Column(String(7), nullable=True) # this member's colour within the group + joined_at = Column(String(50), nullable=True) # ISO 8601 + + group = relationship("Group", back_populates="members") + user = relationship("User") + + +class GroupCalendar(Base): + """1:1 link between a group and its shared local calendar.""" + + __tablename__ = "group_calendars" + __table_args__ = ( + UniqueConstraint("group_id", name="uq_group_calendar_group"), + UniqueConstraint("calendar_id", name="uq_group_calendar_calendar"), + ) + + id = Column(Integer, primary_key=True, index=True) + group_id = Column(Integer, ForeignKey("groups.id"), nullable=False) + calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False) + + group = relationship("Group", back_populates="group_calendar") + calendar = relationship("LocalCalendar") diff --git a/backend/permissions.py b/backend/permissions.py new file mode 100644 index 0000000..3c86c3b --- /dev/null +++ b/backend/permissions.py @@ -0,0 +1,126 @@ +"""Central access control for local calendars. + +Local calendars are visible/writable to a user if any of the following holds: + - the user owns the calendar (LocalCalendar.user_id), + - the calendar is shared with the user (calendar_shares; write needs 'read_write'), + - the calendar is a group calendar and the user is a member of that group + (members get read & write). + +These helpers replace the scattered owner-only filters so sharing and groups +work consistently across every local-calendar endpoint and the event merge read. +""" + +from typing import Optional + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +import models + + +def _share_for(db: Session, calendar_id: int, user_id: int) -> Optional[models.CalendarShare]: + return ( + db.query(models.CalendarShare) + .filter( + models.CalendarShare.calendar_id == calendar_id, + models.CalendarShare.user_id == user_id, + ) + .first() + ) + + +def _is_group_calendar_member(db: Session, calendar_id: int, user_id: int) -> bool: + gc = ( + db.query(models.GroupCalendar) + .filter(models.GroupCalendar.calendar_id == calendar_id) + .first() + ) + if not gc: + return False + member = ( + db.query(models.GroupMember) + .filter( + models.GroupMember.group_id == gc.group_id, + models.GroupMember.user_id == user_id, + ) + .first() + ) + return member is not None + + +def accessible_local_calendar( + db: Session, + user: models.User, + calendar_id: int, + *, + require_write: bool = False, +) -> models.LocalCalendar: + """Return the calendar if the user may access it, else raise 404/403. + + 404 when the calendar does not exist or is not visible to the user (so we + don't leak existence). 403 when it is visible (read) but write is required. + """ + cal = ( + db.query(models.LocalCalendar) + .filter(models.LocalCalendar.id == calendar_id) + .first() + ) + if not cal: + raise HTTPException(404, "Calendar not found") + + if cal.user_id == user.id: + return cal # owner: full access + + if _is_group_calendar_member(db, calendar_id, user.id): + return cal # group members get read & write + + share = _share_for(db, calendar_id, user.id) + if share is None: + raise HTTPException(404, "Calendar not found") + if require_write and share.permission != "read_write": + raise HTTPException(403, "You only have read access to this calendar") + return cal + + +def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> models.LocalCalendar: + """Return the calendar only if the user owns it, else raise 404.""" + cal = ( + db.query(models.LocalCalendar) + .filter( + models.LocalCalendar.id == calendar_id, + models.LocalCalendar.user_id == user.id, + ) + .first() + ) + if not cal: + raise HTTPException(404, "Calendar not found") + return cal + + +def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]: + """All local calendar ids the user may read: own + shared + group calendars.""" + ids: set[int] = set() + + own = ( + db.query(models.LocalCalendar.id) + .filter(models.LocalCalendar.user_id == user.id) + .all() + ) + ids.update(r[0] for r in own) + + shared = ( + db.query(models.CalendarShare.calendar_id) + .filter(models.CalendarShare.user_id == user.id) + .all() + ) + ids.update(r[0] for r in shared) + + group_cals = ( + db.query(models.GroupCalendar.calendar_id) + .join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id) + .filter(models.GroupMember.user_id == user.id) + .all() + ) + ids.update(r[0] for r in group_cals) + + return list(ids) diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py index 98ede29..3aa16bd 100644 --- a/backend/routers/auth_router.py +++ b/backend/routers/auth_router.py @@ -32,7 +32,12 @@ class LoginRequest(BaseModel): 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, + "display_name": user.display_name or user.username, + "is_admin": user.is_admin, + } @router.get("/setup-required") @@ -46,6 +51,7 @@ def setup(req: SetupRequest, db: Session = Depends(get_db)): raise HTTPException(400, "Setup already completed") user = models.User( username=req.username.lower(), + display_name=req.username.strip(), # keep the original casing for display email=req.email, password_hash=get_password_hash(req.password), is_admin=True, @@ -113,6 +119,7 @@ def me(current_user: models.User = Depends(get_current_user)): return { "id": current_user.id, "username": current_user.username, + "display_name": current_user.display_name or current_user.username, "email": current_user.email, "is_admin": current_user.is_admin, "has_avatar": current_user.avatar_filename is not None, diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index 98e2304..d93fc38 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -11,8 +11,16 @@ from sqlalchemy import or_ import caldav_client import models +import permissions from auth import get_current_user from database import get_db +from local_events_util import ( + apply_event_privacy, + build_local_event_dict, + expand_recurring_local, + private_visibility_for, + resolve_creator, +) from routers.ical_router import _refresh_if_needed, get_events_for_subscription logger = logging.getLogger(__name__) @@ -82,101 +90,6 @@ def _account_dict(a: models.CalDAVAccount) -> dict: } -def _expand_recurring_local(ev, local_cal, range_start, range_end): - """Expand a recurring LocalEvent into individual occurrences within the date range.""" - results = [] - # Parse excluded dates - excluded = set() - if ev.exdate: - for d in ev.exdate.split(","): - d = d.strip() - if d: - excluded.add(d) - try: - ev_start_str = ev.start.replace("Z", "+00:00") - ev_end_str = ev.end.replace("Z", "+00:00") - - if ev.all_day: - ev_start = dt_date.fromisoformat(ev_start_str[:10]) - ev_end = dt_date.fromisoformat(ev_end_str[:10]) - duration = ev_end - ev_start - rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time())) - r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time()) - r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time()) - occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) - for occ in occurrences: - occ_start = occ.date() - occ_key = occ_start.strftime("%Y%m%d") - if occ_key in excluded: - continue - occ_end = occ_start + duration - results.append({ - "id": ev.uid, - "url": f"local://{ev.uid}", - "title": ev.title, - "start": occ_start.isoformat(), - "end": occ_end.isoformat(), - "allDay": True, - "location": ev.location or "", - "description": ev.description or "", - "color": ev.color, - "rrule": ev.rrule, - "calendar_id": f"local-{local_cal.id}", - "calendar_name": local_cal.name, - "calendarColor": local_cal.color, - "source": "local", - }) - else: - ev_start = dt_datetime.fromisoformat(ev_start_str) - ev_end = dt_datetime.fromisoformat(ev_end_str) - if ev_start.tzinfo is None: - ev_start = ev_start.replace(tzinfo=dt_timezone.utc) - if ev_end.tzinfo is None: - ev_end = ev_end.replace(tzinfo=dt_timezone.utc) - duration = ev_end - ev_start - rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start) - r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc) - r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc) - if r_start.tzinfo is None: - r_start = r_start.replace(tzinfo=dt_timezone.utc) - if r_end.tzinfo is None: - r_end = r_end.replace(tzinfo=dt_timezone.utc) - occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) - for occ in occurrences: - occ_key = occ.strftime("%Y%m%d") - if occ_key in excluded: - continue - occ_end = occ + duration - results.append({ - "id": ev.uid, - "url": f"local://{ev.uid}", - "title": ev.title, - "start": occ.isoformat(), - "end": occ_end.isoformat(), - "allDay": False, - "location": ev.location or "", - "description": ev.description or "", - "color": ev.color, - "rrule": ev.rrule, - "calendar_id": f"local-{local_cal.id}", - "calendar_name": local_cal.name, - "calendarColor": local_cal.color, - "source": "local", - }) - except Exception as exc: - logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) - # Fall back to single event - results.append({ - "id": ev.uid, "url": f"local://{ev.uid}", "title": ev.title, - "start": ev.start, "end": ev.end, "allDay": ev.all_day, - "location": ev.location or "", "description": ev.description or "", - "color": ev.color, "rrule": ev.rrule, - "calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name, - "calendarColor": local_cal.color, "source": "local", - }) - return results - - def _normalize_url(url: str) -> str: """Normalize URL for comparison: lowercase scheme/host, strip trailing slash.""" parsed = urlparse(url) @@ -417,15 +330,25 @@ def get_events( "Error fetching calendar %s: %s", calendar.id, exc ) - # ── Local calendar events ───────────────────────────── + # ── Local calendar events (own + shared + group calendars) ───────────── + readable_ids = permissions.readable_local_calendar_ids(db, current_user) local_calendars = ( db.query(models.LocalCalendar) .filter( - models.LocalCalendar.user_id == current_user.id, + models.LocalCalendar.id.in_(readable_ids), models.LocalCalendar.enabled == True, ) .all() - ) + ) if readable_ids else [] + name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()} + # Cache each owner's private-event visibility (one lookup per owner, not per event). + vis_cache: dict = {} + + def vis_for(uid: int) -> str: + if uid not in vis_cache: + vis_cache[uid] = private_visibility_for(db, uid) + return vis_cache[uid] + for local_cal in local_calendars: local_events = ( db.query(models.LocalEvent) @@ -441,25 +364,28 @@ def get_events( .all() ) for ev in local_events: + creator = resolve_creator(ev, name_cache=name_cache) + # A private event belonging to someone else (shared calendar or group + # calendar) must honour that owner's private_event_visibility, exactly + # like the group combined view — otherwise private titles/locations + # leak through the ordinary calendar read. + owner_id = ev.creator_id or local_cal.user_id + is_priv = bool(ev.is_private) + foreign_private = is_priv and owner_id != current_user.id + visibility = vis_for(owner_id) if foreign_private else "busy" + if foreign_private and visibility == "hidden": + continue if ev.rrule: - all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt)) + built = expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator) else: - all_events.append({ - "id": ev.uid, - "url": f"local://{ev.uid}", - "title": ev.title, - "start": ev.start, - "end": ev.end, - "allDay": ev.all_day, - "location": ev.location or "", - "description": ev.description or "", - "color": ev.color, - "rrule": None, - "calendar_id": f"local-{local_cal.id}", - "calendar_name": local_cal.name, - "calendarColor": local_cal.color, - "source": "local", - }) + built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)] + for b in built: + b = apply_event_privacy( + b, owner_id=owner_id, is_private=is_priv, + requester_id=current_user.id, visibility=visibility, + ) + if b is not None: + all_events.append(b) # ── iCal subscription events ────────────────────────── ical_subs = ( diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py new file mode 100644 index 0000000..314e088 --- /dev/null +++ b/backend/routers/groups_router.py @@ -0,0 +1,448 @@ +"""Groups: shared group calendar + combined member-calendar overlay view. + +A group has members and exactly one group calendar (a local calendar owned by +the creator, linked via group_calendars). Members get read/write on the group +calendar (enforced by permissions.accessible_local_calendar). The combined view +overlays every member's local calendars plus the group calendar, applying each +member's private-event visibility setting. +""" + +import logging +from datetime import datetime, timezone +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +import models +from auth import get_current_user +from database import get_db +from local_events_util import build_local_event_dict, expand_recurring_local, mask_busy_event + +logger = logging.getLogger(__name__) +router = APIRouter() + +PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"] +# Distinct per-member colours (server-defined so every client shows the same). +MEMBER_PALETTE = ["#4285f4", "#ea4335", "#34a853", "#fbbc05", "#9c27b0", "#ff7043", "#46bdc6", "#7090c0"] + + +def _next_member_color(db: Session, group_id: int) -> str: + n = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).count() + return MEMBER_PALETTE[n % len(MEMBER_PALETTE)] + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class GroupCreate(BaseModel): + name: str + member_ids: List[int] = [] + icon: Optional[str] = None + + +class GroupUpdate(BaseModel): + name: Optional[str] = None + icon: Optional[str] = None + + +class MemberAdd(BaseModel): + user_id: int + + +def _membership(db: Session, group_id: int, user_id: int) -> Optional[models.GroupMember]: + return ( + db.query(models.GroupMember) + .filter( + models.GroupMember.group_id == group_id, + models.GroupMember.user_id == user_id, + ) + .first() + ) + + +def _require_member(db: Session, group: models.Group, user: models.User) -> models.GroupMember: + m = _membership(db, group.id, user.id) + if not m: + raise HTTPException(403, "You are not a member of this group") + return m + + +def _require_owner(db: Session, group: models.Group, user: models.User) -> None: + m = _membership(db, group.id, user.id) + if not m or m.role != "owner": + raise HTTPException(403, "Only the group owner may do this") + + +def _get_group_or_404(db: Session, group_id: int) -> models.Group: + g = db.query(models.Group).filter(models.Group.id == group_id).first() + if not g: + raise HTTPException(404, "Group not found") + return g + + +def _group_calendar_id(db: Session, group_id: int) -> Optional[int]: + gc = ( + db.query(models.GroupCalendar) + .filter(models.GroupCalendar.group_id == group_id) + .first() + ) + return gc.calendar_id if gc else None + + +def _group_calendar_color(db: Session, calendar_id: Optional[int]) -> Optional[str]: + if calendar_id is None: + return None + cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first() + return cal.color if cal else None + + +@router.post("/") +def create_group( + data: GroupCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = models.Group(name=data.name, icon=(data.icon or None), + created_by=current_user.id, created_at=_now_iso()) + db.add(group) + db.flush() + + # Creator is owner; add the requested members (deduped, excluding creator). + # Each member gets a distinct colour from the palette by join order. + db.add(models.GroupMember(group_id=group.id, user_id=current_user.id, role="owner", + color=MEMBER_PALETTE[0], joined_at=_now_iso())) + seen = {current_user.id} + idx = 1 + for uid in data.member_ids: + if uid in seen: + continue + if not db.query(models.User).filter(models.User.id == uid).first(): + continue + db.add(models.GroupMember(group_id=group.id, user_id=uid, role="member", + color=MEMBER_PALETTE[idx % len(MEMBER_PALETTE)], joined_at=_now_iso())) + seen.add(uid) + idx += 1 + + # Auto-create the group calendar (a local calendar owned by the creator). + cal = models.LocalCalendar( + user_id=current_user.id, + name=f"{data.name} (Gruppe)", + color=PALETTE[group.id % len(PALETTE)], + ) + db.add(cal) + db.flush() + db.add(models.GroupCalendar(group_id=group.id, calendar_id=cal.id)) + + db.commit() + db.refresh(group) + return _group_detail(db, group, current_user) + + +@router.get("/") +def list_groups( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + memberships = ( + db.query(models.GroupMember) + .filter(models.GroupMember.user_id == current_user.id) + .all() + ) + out = [] + for m in memberships: + group = db.query(models.Group).filter(models.Group.id == m.group_id).first() + if not group: + continue + member_count = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).count() + gcal_id = _group_calendar_id(db, group.id) + out.append({ + "id": group.id, + "name": group.name, + "icon": group.icon, + "role": m.role, + "member_count": member_count, + "group_calendar_id": gcal_id, + "group_calendar_color": _group_calendar_color(db, gcal_id), + }) + return out + + +def _group_detail(db: Session, group: models.Group, current_user: models.User) -> dict: + members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).all() + member_dicts = [] + for i, m in enumerate(members): + u = db.query(models.User).filter(models.User.id == m.user_id).first() + member_dicts.append({ + "id": m.user_id, + "display_name": (u.display_name or u.username) if u else None, + "role": m.role, + "color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)], + }) + gcal_id = _group_calendar_id(db, group.id) + return { + "id": group.id, + "name": group.name, + "icon": group.icon, + "created_by": group.created_by, + "members": member_dicts, + "group_calendar_id": gcal_id, + "group_calendar_color": _group_calendar_color(db, gcal_id), + } + + +@router.put("/{group_id}") +def update_group( + group_id: int, + data: GroupUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + _require_owner(db, group, current_user) + if data.name is not None and data.name.strip(): + group.name = data.name.strip() + if data.icon is not None: + group.icon = data.icon or None + db.commit() + return _group_detail(db, group, current_user) + + +@router.get("/{group_id}") +def get_group( + group_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + _require_member(db, group, current_user) + return _group_detail(db, group, current_user) + + +@router.post("/{group_id}/members") +def add_member( + group_id: int, + data: MemberAdd, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + _require_owner(db, group, current_user) + if not db.query(models.User).filter(models.User.id == data.user_id).first(): + raise HTTPException(404, "User not found") + if _membership(db, group_id, data.user_id): + return {"ok": True} # already a member + db.add(models.GroupMember(group_id=group_id, user_id=data.user_id, role="member", + color=_next_member_color(db, group_id), joined_at=_now_iso())) + db.commit() + return {"ok": True} + + +class MemberColorUpdate(BaseModel): + color: str + + +@router.put("/{group_id}/members/{user_id}/color") +def set_member_color( + group_id: int, + user_id: int, + data: MemberColorUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + # Owner may recolour anyone; a member may recolour themselves. + if user_id != current_user.id: + _require_owner(db, group, current_user) + else: + _require_member(db, group, current_user) + m = _membership(db, group_id, user_id) + if not m: + raise HTTPException(404, "Member not found") + m.color = data.color + db.commit() + return {"ok": True} + + +@router.delete("/{group_id}/members/{user_id}") +def remove_member( + group_id: int, + user_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + # Owner can remove anyone; a member may remove themselves (leave). + if user_id != current_user.id: + _require_owner(db, group, current_user) + else: + _require_member(db, group, current_user) + target = _membership(db, group_id, user_id) + if not target: + raise HTTPException(404, "Member not found") + if target.role == "owner": + raise HTTPException(422, "The owner cannot be removed; delete the group instead") + db.delete(target) + db.commit() + return {"ok": True} + + +@router.delete("/{group_id}") +def delete_group( + group_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + _require_owner(db, group, current_user) + # Remove the group calendar (and its events) too. + gc = db.query(models.GroupCalendar).filter(models.GroupCalendar.group_id == group_id).first() + if gc: + cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == gc.calendar_id).first() + if cal: + db.delete(cal) # cascades to events + db.delete(group) # cascades to members + group_calendar link + db.commit() + return {"ok": True} + + +def _first_name(name: Optional[str]) -> str: + if not name: + return "" + return name.split(" ", 1)[0] + + +def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict], + owner: Optional[dict], me_id: int) -> str: + """Server-side display title for the combined view so every client (web, + iOS, Android) renders identically: another member's / creator's first name + is prefixed. No icon glyph is embedded — group icons are semantic keys the + clients render as native vector icons, and group-calendar events are + distinguished by their (group) colour. The raw `title` stays for editing.""" + if is_group: + if creator and creator.get("id") is not None and creator.get("id") != me_id: + return f"{_first_name(creator.get('display_name'))}: {title}" + return title + if owner and owner.get("id") is not None and owner.get("id") != me_id: + return f"{_first_name(owner.get('display_name'))}: {title}" + return title + + +@router.get("/{group_id}/combined") +def combined_events( + group_id: int, + start: str = Query(...), + end: str = Query(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + _require_member(db, group, current_user) + + try: + start_dt = datetime.fromisoformat(start.replace("Z", "+00:00")) + end_dt = datetime.fromisoformat(end.replace("Z", "+00:00")) + except ValueError: + raise HTTPException(400, "Invalid date format — use ISO 8601") + if start_dt.tzinfo is None: + start_dt = start_dt.replace(tzinfo=timezone.utc) + if end_dt.tzinfo is None: + end_dt = end_dt.replace(tzinfo=timezone.utc) + + members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).all() + name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()} + visibility_cache: dict[int, str] = {} + + def visibility_for(user_id: int) -> str: + if user_id not in visibility_cache: + s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first() + visibility_cache[user_id] = (s.private_event_visibility if s else None) or "busy" + return visibility_cache[user_id] + + group_cal_id = _group_calendar_id(db, group_id) + group_cal_color = _group_calendar_color(db, group_cal_id) + # Server-defined colours so every client renders members/group consistently. + member_color = { + m.user_id: (m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)]) + for i, m in enumerate(members) + } + all_events: list[dict] = [] + + def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool): + owner_user = name_cache.get(owner_id) + owner = {"id": owner_id, "display_name": owner_user} + events = ( + db.query(models.LocalEvent) + .filter( + models.LocalEvent.calendar_id == cal.id, + or_( + (models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start), + models.LocalEvent.rrule != None, + ), + ) + .all() + ) + for ev in events: + creator_owner_id = ev.creator_id or owner_id + # Private filtering for events that belong to someone else. + if ev.is_private and creator_owner_id != current_user.id: + vis = visibility_for(creator_owner_id) + if vis == "hidden": + continue + creator = None + if ev.creator_id and name_cache.get(ev.creator_id): + creator = {"id": ev.creator_id, "display_name": name_cache[ev.creator_id]} + elif ev.creator_name_external: + creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"} + + if ev.rrule: + built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group) + else: + built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group)] + + for b in built: + if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy": + b = mask_busy_event(b) + # Colour to render with: the group calendar's colour for group + # events, otherwise the owning member's group colour. + b["display_color"] = group_cal_color if is_group else member_color.get(owner_id) + # Decorated title (group icon / owner name) computed server-side + # so all clients render identically; raw `title` kept for editing. + b["display_title"] = _decorate_title( + b.get("title", ""), is_group=is_group, creator=b.get("creator"), + owner=owner, me_id=current_user.id, + ) + all_events.append(b) + + # Each member shares exactly one calendar into their groups, chosen in their + # settings (group_visible_calendar_id). Only that calendar is overlaid. + for m in members: + settings = ( + db.query(models.UserSettings) + .filter(models.UserSettings.user_id == m.user_id) + .first() + ) + visible_id = settings.group_visible_calendar_id if settings else None + if visible_id is None or visible_id == group_cal_id: + continue + cal = ( + db.query(models.LocalCalendar) + .filter( + models.LocalCalendar.id == visible_id, + models.LocalCalendar.user_id == m.user_id, # must be the member's own + ) + .first() + ) + if cal: + emit_calendar(cal, m.user_id, is_group=False) + + # The group calendar itself. + if group_cal_id is not None: + group_cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == group_cal_id).first() + if group_cal: + emit_calendar(group_cal, group_cal.user_id, is_group=True) + + return {"events": all_events} diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index e7d6b97..5f4c980 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -1,17 +1,26 @@ import uuid -from typing import Optional +from datetime import datetime, timezone +from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File +from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy.orm import Session +import ical_io import models +import permissions from auth import get_current_user from database import get_db +from local_events_util import build_local_event_dict, resolve_creator router = APIRouter() +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + class CalendarCreate(BaseModel): name: str color: str = "#34a853" @@ -33,6 +42,8 @@ class EventCreate(BaseModel): description: Optional[str] = None color: Optional[str] = None rrule: Optional[str] = None + private: bool = False + reminders: Optional[List[int]] = None # minutes before start (0 = at start) class EventUpdate(BaseModel): @@ -45,35 +56,34 @@ class EventUpdate(BaseModel): color: Optional[str] = None rrule: Optional[str] = None exdate: Optional[str] = None + private: Optional[bool] = None + reminders: Optional[List[int]] = None -def _cal_dict(cal: models.LocalCalendar) -> dict: - return { +class ShareCreate(BaseModel): + user_id: int + permission: str = "read" + + +def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, + shared_by: Optional[str] = None, permission: Optional[str] = None) -> dict: + d = { "id": cal.id, "name": cal.name, "color": cal.color, "enabled": cal.enabled, + "type": "local", + "owned": owned, } + if shared_by is not None: + d["shared_by"] = shared_by + if permission is not None: + d["permission"] = permission + return d -def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict: - return { - "id": ev.uid, - "url": f"local://{ev.uid}", - "title": ev.title, - "start": ev.start, - "end": ev.end, - "allDay": ev.all_day, - "location": ev.location or "", - "description": ev.description or "", - "color": ev.color, - "rrule": ev.rrule, - "exdate": ev.exdate, - "calendar_id": f"local-{cal.id}", - "calendar_name": cal.name, - "calendarColor": cal.color, - "source": "local", - } +def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict: + return build_local_event_dict(ev, cal, creator=resolve_creator(ev)) # ── Calendar CRUD ───────────────────────────────────────── @@ -83,12 +93,70 @@ def list_calendars( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - cals = ( + # Map calendar_id -> group name for every group the user belongs to, so we + # can flag group calendars as such even when the user owns them (the creator + # owns the group calendar — it must still be marked group:true). + group_cal_map = { + cal_id: name + for cal_id, name in ( + db.query(models.GroupCalendar.calendar_id, models.Group.name) + .join(models.Group, models.Group.id == models.GroupCalendar.group_id) + .join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id) + .filter(models.GroupMember.user_id == current_user.id) + .all() + ) + } + + # Own calendars + own = ( db.query(models.LocalCalendar) .filter(models.LocalCalendar.user_id == current_user.id) .all() ) - return [_cal_dict(c) for c in cals] + result = [] + for c in own: + d = _cal_dict(c, owned=True) + if c.id in group_cal_map: + d["group"] = True + d["shared_by"] = group_cal_map[c.id] # group name, for labelling + result.append(d) + + seen_ids = {c.id for c in own} + + # Calendars shared with this user + shares = ( + db.query(models.CalendarShare) + .filter(models.CalendarShare.user_id == current_user.id) + .all() + ) + for share in shares: + cal = share.calendar + if cal is None or cal.id in seen_ids: + continue + seen_ids.add(cal.id) + owner = db.query(models.User).filter(models.User.id == cal.user_id).first() + d = _cal_dict( + cal, owned=False, + shared_by=(owner.display_name or owner.username) if owner else None, + permission=share.permission, + ) + if cal.id in group_cal_map: + d["group"] = True + result.append(d) + + # Group calendars reached via membership (read_write) that aren't already + # listed, so members can select/see the group calendar. + for cal_id, group_name in group_cal_map.items(): + if cal_id in seen_ids: + continue + cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == cal_id).first() + if not cal: + continue + seen_ids.add(cal_id) + d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write") + d["group"] = True + result.append(d) + return result @router.post("/calendars") @@ -164,16 +232,10 @@ def create_event( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - cal = ( - db.query(models.LocalCalendar) - .filter( - models.LocalCalendar.id == data.calendar_id, - models.LocalCalendar.user_id == current_user.id, - ) - .first() + # Owner, shared (read_write), or group-member calendars are writable. + cal = permissions.accessible_local_calendar( + db, current_user, data.calendar_id, require_write=True ) - if not cal: - raise HTTPException(404, "Calendar not found") ev = models.LocalEvent( calendar_id=cal.id, @@ -186,11 +248,23 @@ def create_event( description=data.description, color=data.color, rrule=data.rrule, + is_private=data.private, + reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None), + creator_id=current_user.id, # server-side, never from the client ) db.add(ev) db.commit() db.refresh(ev) - return _event_dict(ev, cal) + return _event_dict(ev, cal, db) + + +def _writable_event(db: Session, current_user: models.User, uid: str) -> models.LocalEvent: + ev = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first() + if not ev: + raise HTTPException(404, "Event not found") + # Raises 404/403 unless the user may write this event's calendar. + permissions.accessible_local_calendar(db, current_user, ev.calendar_id, require_write=True) + return ev @router.put("/events/{uid}") @@ -200,17 +274,9 @@ def update_event( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - ev = ( - db.query(models.LocalEvent) - .join(models.LocalCalendar) - .filter( - models.LocalEvent.uid == uid, - models.LocalCalendar.user_id == current_user.id, - ) - .first() - ) - if not ev: - raise HTTPException(404, "Event not found") + ev = _writable_event(db, current_user, uid) + if data.private is not None: + ev.is_private = data.private if data.title is not None: ev.title = data.title if data.start is not None: @@ -233,6 +299,8 @@ def update_event( if data.exdate not in dates: dates.append(data.exdate) ev.exdate = ",".join(dates) + if data.reminders is not None: + ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None db.commit() return {"ok": True} @@ -243,17 +311,219 @@ def delete_event( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - ev = ( - db.query(models.LocalEvent) - .join(models.LocalCalendar) - .filter( - models.LocalEvent.uid == uid, - models.LocalCalendar.user_id == current_user.id, - ) - .first() - ) - if not ev: - raise HTTPException(404, "Event not found") + ev = _writable_event(db, current_user, uid) db.delete(ev) db.commit() return {"ok": True} + + +# ── Sharing (owner only) ────────────────────────────────── + +@router.get("/calendars/{calendar_id}/shares") +def list_shares( + calendar_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + permissions.is_calendar_owner(db, current_user, calendar_id) + shares = ( + db.query(models.CalendarShare) + .filter(models.CalendarShare.calendar_id == calendar_id) + .all() + ) + out = [] + for s in shares: + u = db.query(models.User).filter(models.User.id == s.user_id).first() + out.append({ + "user_id": s.user_id, + "display_name": (u.display_name or u.username) if u else None, + "permission": s.permission, + "created_at": s.created_at, + }) + return out + + +@router.post("/calendars/{calendar_id}/shares") +def add_share( + calendar_id: int, + data: ShareCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + permissions.is_calendar_owner(db, current_user, calendar_id) + if data.permission not in ("read", "read_write"): + raise HTTPException(422, "permission must be 'read' or 'read_write'") + target = db.query(models.User).filter(models.User.id == data.user_id).first() + if not target: + raise HTTPException(404, "User not found") + if target.id == current_user.id: + raise HTTPException(422, "Cannot share a calendar with yourself") + + share = ( + db.query(models.CalendarShare) + .filter( + models.CalendarShare.calendar_id == calendar_id, + models.CalendarShare.user_id == data.user_id, + ) + .first() + ) + if share: + share.permission = data.permission # update existing + else: + share = models.CalendarShare( + calendar_id=calendar_id, + user_id=data.user_id, + permission=data.permission, + created_at=_now_iso(), + ) + db.add(share) + db.commit() + return {"ok": True} + + +@router.delete("/calendars/{calendar_id}/shares/{user_id}") +def remove_share( + calendar_id: int, + user_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + permissions.is_calendar_owner(db, current_user, calendar_id) + share = ( + db.query(models.CalendarShare) + .filter( + models.CalendarShare.calendar_id == calendar_id, + models.CalendarShare.user_id == user_id, + ) + .first() + ) + if not share: + raise HTTPException(404, "Share not found") + db.delete(share) + db.commit() + return {"ok": True} + + +# ── iCal Import / Export (local calendars only) ─────────── + +def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict: + parsed = ical_io.parse_ics(raw) + imported = 0 + skipped = 0 + errors = list(parsed["errors"]) + # local_events.uid is globally unique. Dedupe against the DB AND within this + # file — e.g. Nextcloud exports recurring events as several VEVENTs sharing a + # UID (RECURRENCE-ID overrides), which would otherwise violate the constraint. + seen_uids: set[str] = set() + for item in parsed["events"]: + uid = item.get("uid") or str(uuid.uuid4()) + if uid in seen_uids: + skipped += 1 + continue + seen_uids.add(uid) + existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first() + if existing: + skipped += 1 + continue + ev = models.LocalEvent( + calendar_id=cal.id, + uid=uid, + title=item.get("title") or "(ohne Titel)", + start=item["start"], + end=item["end"], + all_day=item.get("all_day", False), + location=item.get("location"), + description=item.get("description"), + rrule=item.get("rrule"), + exdate=item.get("exdate"), + creator_name_external=item.get("organizer"), + ) + db.add(ev) + imported += 1 + try: + db.commit() + except Exception as exc: + db.rollback() + raise ValueError(f"Import fehlgeschlagen: {exc}") + return {"imported": imported, "skipped": skipped, "errors": errors} + + +# Cap .ics uploads so a huge file can't exhaust memory (read fully into RAM). +MAX_ICS_BYTES = 5 * 1024 * 1024 # 5 MB — generous for calendars + + +async def _read_capped(file: UploadFile) -> bytes: + raw = await file.read(MAX_ICS_BYTES + 1) + if len(raw) > MAX_ICS_BYTES: + raise HTTPException(413, "Datei zu groß (max. 5 MB)") + return raw + + +@router.post("/calendars/{calendar_id}/import") +async def import_calendar( + calendar_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True) + raw = await _read_capped(file) + try: + return _import_ics_into(cal, raw, db) + except ValueError as e: + raise HTTPException(422, str(e)) + + +@router.post("/import") +async def import_generic( + file: UploadFile = File(...), + calendar_id: Optional[int] = Form(None), + create_calendar: bool = Form(False), + calendar_name: Optional[str] = Form(None), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + # Read (capped) first so an oversized upload can't leave an empty calendar. + raw = await _read_capped(file) + if create_calendar: + cal = models.LocalCalendar( + user_id=current_user.id, + name=(calendar_name or "Importiert")[:120], + ) + db.add(cal) + db.commit() + db.refresh(cal) + elif calendar_id is not None: + cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True) + else: + raise HTTPException(422, "Provide calendar_id or create_calendar=true") + + try: + result = _import_ics_into(cal, raw, db) + except ValueError as e: + raise HTTPException(422, str(e)) + result["calendar_id"] = cal.id + return result + + +@router.get("/calendars/{calendar_id}/export") +def export_calendar( + calendar_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cal = permissions.accessible_local_calendar(db, current_user, calendar_id) + events = ( + db.query(models.LocalEvent) + .filter(models.LocalEvent.calendar_id == cal.id) + .all() + ) + # Resolve creator display names for ORGANIZER. + name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()} + ics = ical_io.build_ics(cal, events, name_cache=name_cache) + safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar" + return Response( + content=ics, + media_type="text/calendar", + headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'}, + ) diff --git a/backend/routers/profile_router.py b/backend/routers/profile_router.py index 564410b..1f16a37 100644 --- a/backend/routers/profile_router.py +++ b/backend/routers/profile_router.py @@ -1,4 +1,5 @@ import io +import re import base64 from pathlib import Path from typing import Optional @@ -8,11 +9,13 @@ import qrcode from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import FileResponse, Response from PIL import Image -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy.orm import Session +from sqlalchemy import func + import models -from auth import get_current_user, get_password_hash, verify_password +from auth import create_access_token, get_current_user, get_password_hash, verify_password from database import DATA_DIR, get_db router = APIRouter() @@ -25,7 +28,16 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"} # ── Schemas ─────────────────────────────────────────────── class ProfileUpdate(BaseModel): - email: Optional[str] = None + # Length caps (SQLite ignores VARCHAR limits, so enforce here). + email: Optional[str] = Field(default=None, max_length=120) + display_name: Optional[str] = Field(default=None, max_length=80) + username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase) + + +def _strip_controls(s: str) -> str: + """Remove control characters (defends against injected newlines / NULs that + could be reflected in other clients' calendar/sharing/group views).""" + return re.sub(r"[\x00-\x1f\x7f]", "", s).strip() class PasswordChange(BaseModel): @@ -47,6 +59,7 @@ def get_profile(current_user: models.User = Depends(get_current_user)): return { "id": current_user.id, "username": current_user.username, + "display_name": current_user.display_name or current_user.username, "email": current_user.email, "is_admin": current_user.is_admin, "has_avatar": current_user.avatar_filename is not None, @@ -60,10 +73,47 @@ def update_profile( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): + result = {"ok": True} if data.email is not None: - current_user.email = data.email or None + email = _strip_controls(data.email) + if email: + if "@" not in email or "." not in email.split("@")[-1]: + raise HTTPException(422, "Invalid email address") + clash = ( + db.query(models.User) + .filter(func.lower(models.User.email) == email.lower(), + models.User.id != current_user.id) + .first() + ) + if clash: + raise HTTPException(400, "Email already in use") + current_user.email = email + else: + current_user.email = None + if data.display_name is not None: + dn = _strip_controls(data.display_name) + current_user.display_name = dn or current_user.username + if data.username is not None: + new_login = _strip_controls(data.username).lower() + if not new_login: + raise HTTPException(422, "Login name cannot be empty") + if new_login != current_user.username: + taken = ( + db.query(models.User) + .filter(func.lower(models.User.username) == new_login, + models.User.id != current_user.id) + .first() + ) + if taken: + raise HTTPException(400, "Username already taken") + current_user.username = new_login + db.commit() + # The JWT 'sub' is the login name — renaming it invalidates the old + # token, so hand back a fresh one for the client to store. + result["access_token"] = create_access_token({"sub": new_login}) + return result db.commit() - return {"ok": True} + return result # ── Avatar ──────────────────────────────────────────────── diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index a82ecb0..59a418b 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -1,6 +1,6 @@ from typing import Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session @@ -24,6 +24,12 @@ class SettingsUpdate(BaseModel): language: Optional[str] = None month_divider_color: Optional[str] = None month_label_color: Optional[str] = None + text_color: Optional[str] = None + line_color: Optional[str] = None + bg_color: Optional[str] = None + private_event_visibility: Optional[str] = None + group_visible_calendar_id: Optional[int] = None + default_reminder_minutes: Optional[int] = None # null = off def _settings_dict(s: models.UserSettings) -> dict: @@ -40,6 +46,12 @@ def _settings_dict(s: models.UserSettings) -> dict: "language": s.language or "de", "month_divider_color": s.month_divider_color or "#7090c0", "month_label_color": s.month_label_color or "#7090c0", + "text_color": s.text_color, + "line_color": s.line_color, + "bg_color": s.bg_color, + "private_event_visibility": s.private_event_visibility or "busy", + "group_visible_calendar_id": s.group_visible_calendar_id, + "default_reminder_minutes": s.default_reminder_minutes, } @@ -76,8 +88,19 @@ def update_settings( settings = models.UserSettings(user_id=current_user.id) db.add(settings) - for field, value in data.model_dump(exclude_none=True).items(): - setattr(settings, field, value) + if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"): + raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'") + + # For these three override colours, an explicit null is meaningful + # ("reset to default") and must be persisted as NULL. All other fields + # keep the previous behaviour where a null/missing value is ignored. + NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "group_visible_calendar_id", "default_reminder_minutes"} + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if field in NULLABLE_OVERRIDES: + setattr(settings, field, value or None) + elif value is not None: + setattr(settings, field, value) db.commit() return {"ok": True} diff --git a/backend/routers/users_router.py b/backend/routers/users_router.py index 7b50248..3c4831a 100644 --- a/backend/routers/users_router.py +++ b/backend/routers/users_router.py @@ -24,7 +24,13 @@ class ChangePasswordRequest(BaseModel): def _user_dict(u: models.User) -> dict: - return {"id": u.id, "username": u.username, "email": u.email, "is_admin": u.is_admin} + return { + "id": u.id, + "username": u.username, + "display_name": u.display_name or u.username, + "email": u.email, + "is_admin": u.is_admin, + } @router.get("/") @@ -35,6 +41,25 @@ def list_users( return [_user_dict(u) for u in db.query(models.User).all()] +@router.get("/directory") +def user_directory( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + """Lightweight list of all users (id + display_name) for sharing/group pickers. + + Available to any authenticated user (unlike GET / which is admin-only). + Excludes the requesting user. + """ + users = ( + db.query(models.User) + .filter(models.User.id != current_user.id) + .order_by(models.User.username) + .all() + ) + return [{"id": u.id, "display_name": u.display_name or u.username} for u in users] + + @router.post("/") def create_user( req: CreateUserRequest, @@ -45,6 +70,7 @@ def create_user( raise HTTPException(400, "Username already taken") user = models.User( username=req.username.lower(), + display_name=req.username.strip(), # keep the original casing for display email=req.email, password_hash=get_password_hash(req.password), is_admin=req.is_admin, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..105e3fd --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,61 @@ +"""Pytest fixtures: an isolated app + temp SQLite DB, wiped between tests.""" + +import os +import sys +import tempfile +from pathlib import Path + +# Use a throwaway data dir BEFORE importing the app (database.py reads DATA_DIR +# at import time and builds the engine from it). +os.environ.setdefault("DATA_DIR", tempfile.mkdtemp(prefix="calendarr-test-")) +os.environ.setdefault("SECRET_KEY", "test-secret-key") + +BACKEND_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BACKEND_DIR)) + +import pytest +from fastapi.testclient import TestClient + +import main # noqa: E402 (creates tables + runs migrations against the temp DB) +import models # noqa: E402 +from database import engine # noqa: E402 + + +@pytest.fixture +def client(): + return TestClient(main.app) + + +@pytest.fixture(autouse=True) +def clean_db(): + """Wipe every table before each test for isolation.""" + with engine.begin() as conn: + for table in reversed(models.Base.metadata.sorted_tables): + conn.execute(table.delete()) + yield + + +# ── Helpers ─────────────────────────────────────────────── + +def register_admin(client, username="admin", password="pw"): + r = client.post("/api/auth/setup", json={"username": username, "password": password}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def create_user(client, admin_token, username, password="pw"): + r = client.post( + "/api/users/", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"username": username, "password": password}, + ) + assert r.status_code == 200, r.text + uid = r.json()["id"] + # Log in to get the user's own token. + r2 = client.post("/api/auth/login", json={"username": username, "password": password}) + assert r2.status_code == 200, r2.text + return uid, r2.json()["access_token"] + + +def auth(token): + return {"Authorization": f"Bearer {token}"} diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py new file mode 100644 index 0000000..2f2a89c --- /dev/null +++ b/backend/tests/test_collaboration.py @@ -0,0 +1,411 @@ +"""Tests for sharing, group permissions, the iCal parser, and private filtering.""" + +from conftest import register_admin, create_user, auth + +RANGE = {"start": "2026-06-01T00:00:00Z", "end": "2026-06-30T00:00:00Z"} + + +def _make_calendar(client, token, name="Cal"): + r = client.post("/api/local/calendars", headers=auth(token), json={"name": name}) + assert r.status_code == 200, r.text + return r.json()["id"] + + +def _make_event(client, token, cal_id, title="Event", private=False, + start="2026-06-10T10:00:00+00:00", end="2026-06-10T11:00:00+00:00"): + r = client.post("/api/local/events", headers=auth(token), json={ + "calendar_id": cal_id, "title": title, "start": start, "end": end, + "private": private, + }) + assert r.status_code == 200, r.text + return r.json() + + +# ── Sharing ─────────────────────────────────────────────── + +def test_share_read_then_read_write(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + + cal_id = _make_calendar(client, admin, "Admins Kalender") + ev = _make_event(client, admin, cal_id, "Meeting") + + # Creator field populated server-side. + assert ev["creator"]["display_name"] == "admin" + assert ev["type"] == "local" + + # Share read-only with bob. + r = client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin), + json={"user_id": b_id, "permission": "read"}) + assert r.status_code == 200, r.text + + # Bob sees the shared calendar with shared_by. + cals = client.get("/api/local/calendars", headers=auth(b_tok)).json() + shared = [c for c in cals if not c["owned"]] + assert len(shared) == 1 + assert shared[0]["shared_by"] == "admin" + assert shared[0]["permission"] == "read" + + # Bob sees the event in the merged read. + events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"] + assert any(e["title"] == "Meeting" for e in events) + + # Bob cannot write (read-only) -> 403. + r = client.post("/api/local/events", headers=auth(b_tok), json={ + "calendar_id": cal_id, "title": "Nope", + "start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00", + }) + assert r.status_code == 403, r.text + + # Upgrade to read_write -> bob can write. + client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin), + json={"user_id": b_id, "permission": "read_write"}) + r = client.post("/api/local/events", headers=auth(b_tok), json={ + "calendar_id": cal_id, "title": "Bobs Eintrag", + "start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00", + }) + assert r.status_code == 200, r.text + # Created by bob. + assert r.json()["creator"]["display_name"] == "bob" + + +def test_non_owner_cannot_manage_shares(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + cal_id = _make_calendar(client, admin) + # Bob (no access at all) cannot list shares -> 404 (existence hidden). + r = client.get(f"/api/local/calendars/{cal_id}/shares", headers=auth(b_tok)) + assert r.status_code == 404 + + +def test_unshared_calendar_invisible(client): + admin = register_admin(client) + _b_id, b_tok = create_user(client, admin, "bob") + cal_id = _make_calendar(client, admin) + _make_event(client, admin, cal_id, "Privat") + events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"] + assert not any(e["title"] == "Privat" for e in events) + + +# ── Groups ──────────────────────────────────────────────── + +def test_group_create_and_members(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + c_id, c_tok = create_user(client, admin, "carol") + + r = client.post("/api/groups/", headers=auth(admin), + json={"name": "Familie", "member_ids": [b_id]}) + assert r.status_code == 200, r.text + group = r.json() + gid = group["id"] + assert group["group_calendar_id"] is not None + assert {m["display_name"] for m in group["members"]} == {"admin", "bob"} + + # Both members see the group. + assert any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(b_tok)).json()) + # Carol is not a member. + assert not any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(c_tok)).json()) + assert client.get(f"/api/groups/{gid}", headers=auth(c_tok)).status_code == 403 + + # Only owner adds members. + assert client.post(f"/api/groups/{gid}/members", headers=auth(b_tok), + json={"user_id": c_id}).status_code == 403 + assert client.post(f"/api/groups/{gid}/members", headers=auth(admin), + json={"user_id": c_id}).status_code == 200 + + # Member can leave; owner cannot be removed. + assert client.delete(f"/api/groups/{gid}/members/{c_id}", headers=auth(c_tok)).status_code == 200 + admin_id = client.get("/api/auth/me", headers=auth(admin)).json()["id"] + assert client.delete(f"/api/groups/{gid}/members/{admin_id}", headers=auth(admin)).status_code == 422 + + +def test_group_members_can_write_group_calendar(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gcal = group["group_calendar_id"] + # Bob (member, not owner of the calendar) can create in the group calendar. + r = client.post("/api/local/events", headers=auth(b_tok), json={ + "calendar_id": gcal, "title": "Teamtermin", + "start": "2026-06-12T09:00:00+00:00", "end": "2026-06-12T10:00:00+00:00", + }) + assert r.status_code == 200, r.text + + +def test_group_member_colors_and_display_color(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gid = group["id"] + gcal = group["group_calendar_id"] + + # Each member has a server-assigned colour; the group exposes its calendar colour. + detail = client.get(f"/api/groups/{gid}", headers=auth(admin)).json() + assert all(m.get("color") for m in detail["members"]) + assert detail.get("group_calendar_color") + + # Owner can recolour a member. + r = client.put(f"/api/groups/{gid}/members/{b_id}/color", headers=auth(admin), + json={"color": "#123456"}) + assert r.status_code == 200, r.text + detail2 = client.get(f"/api/groups/{gid}", headers=auth(admin)).json() + assert any(m["id"] == b_id and m["color"] == "#123456" for m in detail2["members"]) + + # Bob shares a calendar with an event; combined events carry display_color. + b_cal = _make_calendar(client, b_tok, "Bobs Kalender") + client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal}) + _make_event(client, b_tok, b_cal, "Bobs Termin") + _make_event(client, admin, gcal, "Gruppentermin") + evs = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"] + by_title = {e["title"]: e for e in evs} + assert by_title["Bobs Termin"]["display_color"] == "#123456" # Bob's member colour + assert by_title["Gruppentermin"]["display_color"] == detail2["group_calendar_color"] + + +def test_group_calendar_listed_for_member(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gcal = group["group_calendar_id"] + # Bob (member, not owner) sees the group calendar in his local list, flagged. + cals = client.get("/api/local/calendars", headers=auth(b_tok)).json() + gc = [c for c in cals if c["id"] == gcal] + assert gc and gc[0].get("group") is True + assert gc[0]["permission"] == "read_write" and gc[0]["owned"] is False + + +def test_combined_view_marks_owner_and_group_event(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gid = group["id"] + gcal = group["group_calendar_id"] + + # Bob's own calendar + event; Bob designates it as his group-visible calendar. + b_cal = _make_calendar(client, b_tok, "Bobs Kalender") + client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal}) + _make_event(client, b_tok, b_cal, "Bobs Termin") + # A group-calendar event. + _make_event(client, admin, gcal, "Gruppentermin") + + events = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"] + titles = {e["title"]: e for e in events} + assert "Bobs Termin" in titles + assert titles["Bobs Termin"]["owner"]["display_name"] == "bob" + assert titles["Bobs Termin"].get("is_group_event") is not True + assert "Gruppentermin" in titles + assert titles["Gruppentermin"]["is_group_event"] is True + + +# ── Private filtering ───────────────────────────────────── + +def _combined_titles(client, token, gid): + evs = client.get(f"/api/groups/{gid}/combined", headers=auth(token), params=RANGE).json()["events"] + return evs + + +def test_private_visibility_hidden_and_busy(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gid = group["id"] + + b_cal = _make_calendar(client, b_tok, "Bobs Kalender") + client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal}) + _make_event(client, b_tok, b_cal, "Geheimes", private=True, + start="2026-06-15T10:00:00+00:00", end="2026-06-15T11:00:00+00:00") + + # Bob sees his own private event in full. + own = _combined_titles(client, b_tok, gid) + assert any(e["title"] == "Geheimes" for e in own) + + # Default visibility = busy -> admin sees it as anonymous "Beschäftigt". + seen = _combined_titles(client, admin, gid) + busy = [e for e in seen if e["start"].startswith("2026-06-15")] + assert busy and all(e["title"] == "Beschäftigt" for e in busy) + assert all(e["location"] == "" and e["description"] == "" for e in busy) + + # Switch bob to hidden -> admin no longer sees it at all. + client.put("/api/settings/", headers=auth(b_tok), json={"private_event_visibility": "hidden"}) + seen2 = _combined_titles(client, admin, gid) + assert not any(e["start"].startswith("2026-06-15") for e in seen2) + + +def test_member_calendar_hidden_until_designated(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gid = group["id"] + b_cal = _make_calendar(client, b_tok, "Bobs Kalender") + _make_event(client, b_tok, b_cal, "Bobs Termin") + + # Not designated yet -> admin doesn't see Bob's calendar in the combined view. + seen = _combined_titles(client, admin, gid) + assert not any(e["title"] == "Bobs Termin" for e in seen) + + # After Bob designates it, it appears. + client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal}) + seen2 = _combined_titles(client, admin, gid) + assert any(e["title"] == "Bobs Termin" for e in seen2) + + +def test_display_name_case_preserved_and_login_case_insensitive(client): + # Setup with mixed-case name: login name lowercased, display name kept. + r = client.post("/api/auth/setup", json={"username": "Guido", "password": "pw"}) + assert r.status_code == 200, r.text + user = r.json()["user"] + assert user["username"] == "guido" + assert user["display_name"] == "Guido" + + # Login is case-insensitive (typing the display name "GUIDO" works). + r2 = client.post("/api/auth/login", json={"username": "GUIDO", "password": "pw"}) + assert r2.status_code == 200, r2.text + tok = r2.json()["access_token"] + + # /me reflects the cased display name. + me = client.get("/api/auth/me", headers=auth(tok)).json() + assert me["display_name"] == "Guido" and me["username"] == "guido" + + +def test_rename_login_name_returns_new_token(client): + admin = register_admin(client, "alice") + # Change display name (no token change). + r = client.put("/api/profile/", headers=auth(admin), json={"display_name": "Alice W."}) + assert r.status_code == 200 and "access_token" not in r.json() + # Change login name -> fresh token, references survive (id is stable). + r2 = client.put("/api/profile/", headers=auth(admin), json={"username": "alice2"}) + assert r2.status_code == 200 and r2.json().get("access_token") + new_tok = r2.json()["access_token"] + me = client.get("/api/auth/me", headers=auth(new_tok)).json() + assert me["username"] == "alice2" and me["display_name"] == "Alice W." + + +def test_private_visibility_validation(client): + admin = register_admin(client) + r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"}) + assert r.status_code == 422 + + +# ── iCal import/export ──────────────────────────────────── + +SAMPLE_ICS = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:evt-1@test +SUMMARY:Importiert 1 +DTSTART:20260620T100000Z +DTEND:20260620T110000Z +LOCATION:Buero +ORGANIZER;CN=Max Mustermann:mailto:max@example.com +RRULE:FREQ=WEEKLY;BYDAY=MO +END:VEVENT +BEGIN:VEVENT +UID:evt-2@test +SUMMARY:Importiert 2 +DTSTART;VALUE=DATE:20260621 +DTEND;VALUE=DATE:20260622 +END:VEVENT +END:VCALENDAR +""" + + +def test_ical_parser_roundtrip(): + import ical_io + parsed = ical_io.parse_ics(SAMPLE_ICS) + assert len(parsed["events"]) == 2 + ev1 = next(e for e in parsed["events"] if e["uid"] == "evt-1@test") + assert ev1["title"] == "Importiert 1" + assert ev1["location"] == "Buero" + assert ev1["organizer"] == "Max Mustermann" + assert ev1["rrule"] == "FREQ=WEEKLY;BYDAY=MO" + ev2 = next(e for e in parsed["events"] if e["uid"] == "evt-2@test") + assert ev2["all_day"] is True + + +def test_import_dedupes_by_uid(client): + admin = register_admin(client) + cal_id = _make_calendar(client, admin, "Import-Ziel") + + files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")} + r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files) + assert r.status_code == 200, r.text + body = r.json() + assert body["imported"] == 2 and body["skipped"] == 0 + + # Re-import -> all skipped (UID dedupe). + files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")} + r2 = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files) + assert r2.json()["imported"] == 0 and r2.json()["skipped"] == 2 + + # Imported events carry the external creator name. + events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"] + imported = [e for e in events if e["title"] == "Importiert 1"] + assert imported and imported[0]["creator"]["display_name"] == "Max Mustermann (importiert)" + + +DUP_UID_ICS = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nextcloud +BEGIN:VEVENT +UID:recurring@nc +SUMMARY:Standup +DTSTART:20260601T090000Z +DTEND:20260601T091500Z +RRULE:FREQ=WEEKLY;BYDAY=MO +END:VEVENT +BEGIN:VEVENT +UID:recurring@nc +RECURRENCE-ID:20260608T090000Z +SUMMARY:Standup verschoben +DTSTART:20260608T100000Z +DTEND:20260608T101500Z +END:VEVENT +END:VCALENDAR +""" + + +def test_import_handles_duplicate_uid_in_file(client): + """Nextcloud exports recurring events as multiple VEVENTs sharing a UID; + importing must not 500 on the unique constraint.""" + admin = register_admin(client) + cal_id = _make_calendar(client, admin, "NC") + files = {"file": ("nc.ics", DUP_UID_ICS, "text/calendar")} + r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files) + assert r.status_code == 200, r.text + body = r.json() + assert body["imported"] == 1 and body["skipped"] == 1 + + +def test_export_contains_organizer_and_rrule(client): + admin = register_admin(client) + cal_id = _make_calendar(client, admin, "Export-Test") + _make_event(client, admin, cal_id, "Wöchentlich") + # Add a recurring rule via update. + events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"] + uid = next(e["id"] for e in events if e["title"] == "Wöchentlich") + client.put(f"/api/local/events/{uid}", headers=auth(admin), json={"rrule": "FREQ=WEEKLY;BYDAY=MO"}) + + r = client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(admin)) + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/calendar") + body = r.text + assert "BEGIN:VCALENDAR" in body + assert "ORGANIZER" in body and "admin" in body + assert "RRULE" in body + + +def test_import_export_only_local(client): + """Import/export endpoints reject non-existent / inaccessible calendars.""" + admin = register_admin(client) + _b, b_tok = create_user(client, admin, "bob") + cal_id = _make_calendar(client, admin, "Privat") + # Bob has no access -> 404 on export. + assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404 diff --git a/frontend/css/app.css b/frontend/css/app.css index 556a5db..e3f6e06 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -55,49 +55,129 @@ a { color: var(--primary); text-decoration: none; } .flex-col { display: flex; flex-direction: column; } .gap-8 { gap: 8px; } -/* ── Buttons ────────────────────────────────────────────── */ +/* ── Buttons ────────────────────────────────────────────── + Modern pill style: fully rounded, subtle coloured shadow on the + prominent variants, lift on hover, snap back on press. The + primary-coloured glow follows --primary via color-mix(), so it adapts + when the user changes the theme colour in settings. */ .btn { - display: inline-flex; align-items: center; gap: 6px; - padding: 8px 16px; border-radius: 20px; - font-weight: 500; transition: background var(--transition), color var(--transition); + display: inline-flex; align-items: center; justify-content: center; + gap: 8px; + padding: 10px 22px; + border-radius: 999px; + font-weight: 500; font-size: 14px; + letter-spacing: .1px; white-space: nowrap; + user-select: none; + -webkit-tap-highlight-color: transparent; + transition: + background var(--transition), + color var(--transition), + border-color var(--transition), + box-shadow .18s ease, + transform .12s ease, + filter var(--transition); } +.btn:active { transform: translateY(0) scale(.985); transition-duration: .05s; } +.btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + .btn-primary { background: var(--primary); color: #fff; + box-shadow: 0 2px 8px rgba(66,133,244,.28); + box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 30%, transparent); } -.btn-primary:hover { filter: brightness(1.12); } +.btn-primary:hover { + filter: brightness(1.08); + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(66,133,244,.42); + box-shadow: 0 6px 18px color-mix(in srgb, var(--primary) 45%, transparent); +} + .btn-secondary { background: var(--bg-surface); color: var(--text-1); border: 1px solid var(--border); } -.btn-secondary:hover { background: var(--bg-hover); } -.btn-ghost { color: var(--primary); } -.btn-ghost:hover { background: var(--primary-dim); } -.btn-danger { background: var(--accent); color: #fff; } -.btn-danger:hover { filter: brightness(1.1); } -.btn-full { width: 100%; justify-content: center; } +.btn-secondary:hover { + background: var(--bg-hover); + border-color: var(--primary); + transform: translateY(-1px); +} + +.btn-ghost { + color: var(--primary); + background: transparent; +} +.btn-ghost:hover { + background: var(--primary-dim); + transform: translateY(-1px); +} + +.btn-danger { + background: var(--accent); + color: #fff; + box-shadow: 0 2px 8px rgba(234,67,53,.28); + box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 30%, transparent); +} +.btn-danger:hover { + filter: brightness(1.08); + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(234,67,53,.42); + box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 45%, transparent); +} + +.btn-full { width: 100%; } + +/* The big sidebar "Erstellen" button: same pill aesthetic, primary tinted, + lives in the calm dark sidebar so the shadow is a touch stronger. */ .btn-fab { display: flex; align-items: center; gap: 10px; - padding: 12px 20px; border-radius: 24px; - background: var(--bg-surface); - color: var(--text-1); + padding: 12px 22px; + border-radius: 999px; + background: var(--primary); + color: #fff; font-weight: 600; - box-shadow: var(--shadow); margin: 16px 12px 8px; - transition: background var(--transition), box-shadow var(--transition); + box-shadow: 0 4px 14px rgba(66,133,244,.32); + box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 35%, transparent); + transition: + background var(--transition), + box-shadow .18s ease, + transform .12s ease, + filter var(--transition); } -.btn-fab:hover { background: var(--bg-hover); box-shadow: var(--shadow-lg); } +.btn-fab:hover { + filter: brightness(1.08); + transform: translateY(-1px); + box-shadow: 0 8px 22px rgba(66,133,244,.5); + box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 50%, transparent); +} +.btn-fab:active { transform: translateY(0) scale(.985); } +/* Circular icon buttons (topbar nav, modal close, etc.) */ .icon-btn { display: inline-flex; align-items: center; justify-content: center; - width: 40px; height: 40px; border-radius: 50%; - color: var(--text-2); transition: background var(--transition); + width: 40px; height: 40px; + border-radius: 50%; + color: var(--text-2); flex-shrink: 0; + -webkit-tap-highlight-color: transparent; + transition: + background var(--transition), + color var(--transition), + transform .1s ease; } .icon-btn svg { width: 20px; height: 20px; fill: currentColor; } .icon-btn:hover { background: var(--bg-hover); color: var(--text-1); } +.icon-btn:active { transform: scale(.92); } +.icon-btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} /* ── Auth Screens ───────────────────────────────────────── */ .auth-screen { @@ -140,15 +220,22 @@ a { color: var(--primary); text-decoration: none; } .form-group input, .form-group select, .form-group textarea { background: var(--bg-app); border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 10px 12px; + border-radius: 8px; + padding: 11px 14px; color: var(--text-1); outline: none; - transition: border-color var(--transition); + transition: border-color var(--transition), box-shadow var(--transition); width: 100%; } +.form-group input:hover:not(:focus), +.form-group select:hover:not(:focus), +.form-group textarea:hover:not(:focus) { + border-color: var(--text-3); +} .form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(66,133,244,.18); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); } .form-group textarea { resize: vertical; } @@ -448,8 +535,10 @@ a { color: var(--primary); text-decoration: none; } outline: none; } .cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; } -.cal-item-remove { opacity: 0; } -.cal-item:hover .cal-item-remove { opacity: 1; } +/* Hide the remove/eye button until hover so the calendar name uses the full + width and only truncates while the button is visible. */ +.cal-item-remove { display: none; } +.cal-item:hover .cal-item-remove { display: inline-flex; } /* ── Month View ─────────────────────────────────────────── */ .month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } @@ -493,7 +582,8 @@ a { color: var(--primary); text-decoration: none; } .cell-day { font-size: 12px; font-weight: 500; color: var(--text-2); width: 26px; height: 26px; - display: flex; align-items: center; justify-content: center; + display: inline-flex; align-items: center; justify-content: center; + vertical-align: top; border-radius: 50%; flex-shrink: 0; } .cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; } @@ -502,13 +592,8 @@ a { color: var(--primary); text-decoration: none; } .month-col { position: relative; /* anchor for divider pseudo-elements */ } -.month-col.first-of-month { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0; - padding-top: 8px; -} +/* first-of-month keeps the normal block layout (number top-left); the month + label sits inline next to the number via .cell-day/.month-marker below. */ /* Dividers via pseudo-elements so they render above events (z-index 2) */ .month-col.month-divider-left::before { content: ''; @@ -552,14 +637,14 @@ a { color: var(--primary); text-decoration: none; } pointer-events: none; } .month-marker { - font-size: 14px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: .5px; + font-size: 11px; + font-weight: 600; + text-transform: none; + letter-spacing: .3px; color: var(--month-label-color, #7090c0); - line-height: 1; - padding: 0 2px; - margin: 0 0 2px 4px; + line-height: 26px; /* align with the day-number circle */ + margin-left: 4px; + vertical-align: top; position: relative; z-index: 3; /* above events overlay (z-index 2) */ } @@ -568,10 +653,10 @@ a { color: var(--primary); text-decoration: none; } position: relative; z-index: 3; } -/* Push events overlay down when row contains a first-of-month cell so the - day "1" (which sits below the month marker) isn't hidden by event bars */ +/* Month marker now sits inline next to the day number, so the header height is + uniform and the events overlay needs no extra offset for month-start weeks. */ .month-row.has-month-marker .month-events-overlay { - top: 56px; + top: 30px; } /* Events overlay — pointer-events:none so clicks pass to columns */ .month-events-overlay { @@ -888,46 +973,131 @@ a { color: var(--primary); text-decoration: none; } } .ctx-item:hover { background: var(--bg-hover); } -/* ── Event Popup ────────────────────────────────────────── */ +/* ── Event Popup ────────────────────────────────────────── + Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben. + Icons sind im Ruhezustand transparent (nur das SVG selbst sichtbar), + bekommen erst beim Hover einen runden farbigen Hintergrund. Wirkt + modern und lässt dem Titel die meiste Breite. */ .event-popup { position: fixed; z-index: 600; - background: var(--bg-surface); - border: 1px solid var(--border); - border-radius: var(--radius); - width: 300px; - box-shadow: var(--shadow-lg); + width: 320px; + background: var(--bg-surface); /* fallback for no color-mix */ + background: color-mix(in srgb, var(--bg-surface) 86%, transparent); + -webkit-backdrop-filter: blur(22px) saturate(1.6); + backdrop-filter: blur(22px) saturate(1.6); + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: 16px; + box-shadow: + inset 0 1px 0 rgba(255,255,255,.05), + 0 12px 40px rgba(0,0,0,.55), + 0 2px 8px rgba(0,0,0,.4); + overflow: hidden; + transform-origin: top left; + animation: popupIn .18s cubic-bezier(.2,.8,.2,1); } +@keyframes popupIn { + from { opacity: 0; transform: translateY(6px) scale(.97); } + to { opacity: 1; transform: none; } +} +@media (prefers-reduced-motion: reduce) { + .event-popup { animation: none; } +} + .popup-header { - display: flex; align-items: center; gap: 8px; - padding: 12px 16px; border-bottom: 1px solid var(--border); + position: relative; + display: flex; align-items: flex-start; gap: 11px; + padding: 14px 10px 13px 18px; + background: linear-gradient(180deg, + color-mix(in srgb, var(--ev-color, var(--primary)) 13%, transparent), transparent); } -.popup-color-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } -.popup-header h4 { flex: 1; font-size: 15px; font-weight: 500; } -.popup-action, .popup-close { width: 32px; height: 32px; font-size: 16px; } -.popup-body { padding: 12px 16px; } -.popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; } -.popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; } +/* Slim accent strip in the event's colour. */ +.popup-header::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; + background: var(--ev-color, var(--primary)); +} +.popup-color-dot { + width: 10px; height: 10px; border-radius: 50%; + background: var(--ev-color, var(--primary)); + flex-shrink: 0; margin-top: 6px; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ev-color, var(--primary)) 22%, transparent); +} +.popup-header h4 { + flex: 1; + font-size: 15px; font-weight: 600; + line-height: 1.35; letter-spacing: -.01em; + color: var(--text-1); + word-break: break-word; + padding-top: 1px; +} + +.popup-toolbar { + display: flex; + gap: 2px; + flex-shrink: 0; + margin-left: 4px; +} +.popup-icon-btn { + width: 32px; height: 32px; + border-radius: 10px; + display: inline-flex; align-items: center; justify-content: center; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-3); + -webkit-tap-highlight-color: transparent; + transition: + background var(--transition), + color var(--transition), + transform .12s ease; +} +.popup-icon-btn svg { width: 16px; height: 16px; fill: currentColor; flex-shrink: 0; } +.popup-icon-btn:hover { + background: rgba(66,133,244,.16); + background: color-mix(in srgb, var(--primary) 16%, transparent); + color: var(--primary); +} +.popup-icon-btn-danger:hover { + background: rgba(234,67,53,.16); + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); +} +.popup-icon-btn-close:hover { + background: var(--bg-hover); + color: var(--text-1); +} +.popup-icon-btn:active { transform: scale(.88); } + +.popup-body { padding: 12px 16px 14px; display: flex; flex-direction: column; gap: 9px; } +.popup-row { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.45; color: var(--text-2); } +.popup-row-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; fill: var(--text-3); } +#popup-time { color: var(--text-1); font-weight: 500; } +.popup-row-desc { color: var(--text-1); } +.popup-row-desc span { white-space: pre-wrap; } +#popup-creator { font-style: italic; } + .popup-copy-menu { - border-top: 1px solid var(--border); - padding: 4px 0; + border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + padding: 6px; } .popup-copy-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; color: var(--text-3); - padding: 4px 14px 6px; + padding: 4px 10px 6px; } .popup-copy-item { display: flex; align-items: center; gap: 9px; - padding: 7px 14px; cursor: pointer; font-size: 13px; color: var(--text-1); + padding: 8px 10px; cursor: pointer; font-size: 13px; color: var(--text-1); + border-radius: 10px; + transition: background var(--transition); } .popup-copy-item:hover { background: var(--bg-hover); } .popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .popup-copy-edit-toggle { display: flex; align-items: center; gap: 8px; - padding: 6px 14px 8px; + padding: 6px 10px 8px; font-size: 12px; color: var(--text-2); cursor: pointer; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); margin-bottom: 4px; } .popup-copy-edit-toggle input[type="checkbox"] { margin: 0; cursor: pointer; } @@ -1557,15 +1727,12 @@ a { color: var(--primary); text-decoration: none; } .topbar-left { gap: 0; } .topbar-right { gap: 0; } - /* Event-Popup: Buttons kompakt halten, kein 44px-Override ───── */ - .event-popup .icon-btn { - min-width: 32px !important; - min-height: 32px !important; - width: 32px; - height: 32px; - } - .event-popup .popup-header { gap: 2px; padding: 10px 12px; } - .event-popup { width: min(92vw, 340px); max-width: 92vw; } + /* Event-Popup auf Mobile: an Viewport-Breite anpassen */ + .event-popup { width: min(94vw, 360px); max-width: 94vw; } + .popup-header { padding: 12px 8px 11px 16px; } + .popup-header h4 { font-size: 14.5px; } + .popup-icon-btn { width: 36px; height: 36px; } + .popup-icon-btn svg { width: 17px; height: 17px; } /* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */ .month-event-time { display: none; } @@ -1627,3 +1794,180 @@ a { color: var(--primary); text-decoration: none; } } } + +/* ── Collaboration: sharing badges & user picker ───────────── */ +.cal-badge { + display: inline-block; + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: var(--bg-surface); + color: var(--text-2); + border: 1px solid var(--border); + white-space: nowrap; +} +.cal-badge-shared { + background: rgba(66, 133, 244, 0.15); + color: var(--primary); + border-color: transparent; +} +.share-user-picker { + margin-top: 8px; + max-height: 220px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 10px; +} +.share-user-item { + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid var(--border); +} +.share-user-item:last-child { border-bottom: none; } +.share-user-item:hover { background: var(--bg-surface); } +/* .popup-creator styling moved into the .popup-row / #popup-creator rules above. */ + +/* ── Groups ─────────────────────────────────────────────────── */ +.group-view-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 16px; + background: var(--bg-surface); /* fallback for browsers without color-mix */ + background: color-mix(in srgb, var(--accent) 15%, var(--bg-app)); + border-bottom: 1px solid var(--accent); + font-size: 14px; + color: var(--text-1); +} +/* Group-member rows in the sidebar (colour dot = per-user colour, checkbox = + show/hide). Reuse the calendar-list item styling. */ +.gm-row .gm-dot { cursor: pointer; } + +.group-item-active { + background: var(--bg-surface); + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-radius: 8px; +} +.group-item .cal-item-name { cursor: pointer; flex: 1; } +.cal-list-empty { + padding: 6px 4px; + font-size: 13px; + color: var(--text-3); +} + +/* Group member picker rows — checkbox left, name left, one per line. */ +.group-member-item { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid var(--border); + text-align: left; + text-transform: none; +} +.group-member-item:last-child { border-bottom: none; } +.group-member-item:hover { background: var(--bg-surface); } +.group-member-item input[type="checkbox"] { flex: 0 0 auto; margin: 0; } +.group-member-name { flex: 1 1 auto; color: var(--text-1); } + +/* Calendar radio list (group-visible selection in settings). */ +.cal-radio-list { + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} +.cal-radio-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid var(--border); +} +.cal-radio-item:last-child { border-bottom: none; } +.cal-radio-item:hover { background: var(--bg-surface); } +.cal-radio-item .cal-item-dot { border-radius: 50%; flex: 0 0 auto; } + +/* Picker rows (group-visible calendar radio + group member checkboxes). + Deliberately NOT