Fuenf Farbspalten trugen einen fest eingebauten Vorgabewert — primary_color
#4285f4, accent_color #ea4335, today_color #4285f4, month_divider_color und
month_label_color #7090c0. Damit hatte *jeder* Nutzer immer eine eigene
Farbe, und der Fallback settings.X || baseColor(X) kam nie zum Zug. Das
Admin-Versprechen "Gilt fuer alle Nutzer ohne eigene Farbe" war fuer diese
fuenf unerfuellbar, und die Werte widersprachen DEFAULT_COLORS im Frontend,
das laut Kommentar die einzige Quelle sein soll.
Das betraf nicht nur Altbestand: UserSettings wird ohne Farbwerte angelegt,
also bekam auch jeder heute neu angelegte Nutzer das Blau.
- models.py: die fuenf Spalten sind nullable, NULL heisst "erbt".
- settings_router: NULL wird ausgeliefert statt durch einen Standard ersetzt,
und ein explizites null wird gespeichert (NULLABLE_OVERRIDES).
- main.py: einmalige Migration setzt genau die alten Vorgabewerte auf NULL.
Bewusst gewaehlte Farben bleiben unangetastet.
- calendar.js: eine erbende Zeile merkt sich das und sendet beim Speichern
null. "Zurueck" heisst wieder erben, statt den aktuellen Standard in die
Nutzerzeile zu kopieren — sonst erreichen spaetere Aenderungen des
Standards den Nutzer nie mehr.
Verifiziert: frische Installation liefert --primary #58B900 statt #4285f4;
zurueckgeschriebenes Alt-Blau wird beim Neustart geleert; eine eigene Farbe
(#FF00AA) ueberlebt die Migration. Suite 129 gruen, davon 5 neue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Im Editor werden Dateien gestaget und erst beim Speichern angewendet --
sonst haette ein neuer Termin keine UID, an die der Anhang gehen koennte,
und "Abbrechen" wuerde nicht mehr bedeuten, dass nichts passiert ist.
Schlaegt danach ein Upload fehl, sagt der Toast das, statt Erfolg
vorzutaeuschen.
Das Popup laedt die Liste erst beim Oeffnen, weil das Event-JSON nur die
Anzahl traegt. Thumbnails gehen ueber authentifiziertes fetch auf eine
Blob-URL, weil ein img-Tag den Token nicht mitschicken kann -- die URL
wird nach dem Dekodieren freigegeben, anders als an den beiden
bestehenden Stellen, die sie liegen lassen.
Die fest verdrahtete Popup-Hoehe von 200 px wird jetzt gemessen; mit einer
Anhangliste stimmte die Annahme nicht mehr.
api.js haengt den HTTP-Status an geworfene Fehler, damit die Oberflaeche
409/413/400 uebersetzen kann statt das deutsche Backend-Detail zu zeigen.
Der ASCII-Rueckfallname im Content-Disposition war keiner: isalnum() ist
fuer Umlaute wahr. Faellt beim Herunterladen von "Rechnung Zahnaerztin.pdf"
auf, jetzt mit Test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jeder Anhang wird als ATTACH mit FMTTYPE, SIZE und FILENAME ausgegeben,
als URI statt inline base64 -- bei 10 MB je Datei wuerde sonst bei jeder
Synchronisation der ganze Anhang im ICS-Rumpf mitgeschleppt.
Die absolute URL kommt aus dav_util.public_base, das PUBLIC_BASE_URL und
die Forwarded-Header schon behandelt; ohne Basis-URL wird die Zeile
weggelassen, weil eine relative ATTACH-URL einem externen Client nichts
nuetzt. Abschaltbar ueber ATTACHMENT_PUBLIC_LINKS=0.
Der CalDAV-PUT ist geprueft: ein externer Client, der den Termin
zurueckschreibt, laesst die Anhaenge unberuehrt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Das Backend hatte bisher keinerlei Hintergrund-Mechanik. Neues Modul mit
einer Job-Registry, angebunden ueber den FastAPI-Lifespan. Der Server
laeuft als genau ein Prozess, ein In-Process-Loop laeuft damit garantiert
nur einmal -- steht als Vorbehalt im Modulkopf, falls spaeter Worker
dazukommen.
Jeder Job laeuft im Thread mit eigener Session, weil SQLAlchemy hier
synchron ist und Datei- oder Netz-I/O sonst den Event-Loop blockiert.
Fehler werden pro Job gefangen.
Zwei Jobs: der Anhang-Sweep alle 24 h und die iCal-Aktualisierung alle
15 min. Letztere treibt nur den vorhandenen _refresh_if_needed an, der das
Intervall je Abo schon respektiert -- neu ist allein, dass Abos auch
aktuell bleiben, wenn niemand die App offen hat. Jedes Abo einzeln
gekapselt, weil der Helfer ohne Cache weiterwirft.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Die Terminliste traegt nur die Anzahl, nicht die Anhaenge selbst. Der
Merge-Read laeuft pro Termin durch eine Schleife -- eine Lazy-Relationship
waere dort eine Abfrage je Termin ueber eine ganze Monatsansicht, und ein
wiederkehrender Termin wuerde dieselbe Liste an jeder Instanz wiederholen.
Stattdessen je Kalender eine gruppierte Abfrage; die Liste holen die
Clients beim Oeffnen der Detailansicht.
mask_busy_event setzt die Zahl ausdruecklich auf 0: dass ein als
"Beschaeftigt" maskierter Termin drei Dateien haette, ist auch eine
Information.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neuer Router unter /api/local mit Upload, Liste, Download, Thumbnail und
Loeschen, dazu permissions.readable_local_event als fehlendes Gegenstueck
zu _writable_event.
Der Lese-Helfer prueft nicht nur den Kalender, sondern auch die
Privatsphaere: ein fremder privater Termin wird im Lesepfad maskiert, also
duerfen auch seine Anhaenge nicht ueber die UID erreichbar sein. Fremde
bekommen durchgaengig 404 statt 403, damit die Existenz nicht durchsickert.
Die Mengenbegrenzung zaehlt nach dem Insert innerhalb derselben
Transaktion -- zaehlen-dann-einfuegen waere ein Wettlauf, bei dem zwei
gleichzeitige Uploads beide 9 sehen.
Ausgeliefert wird immer als Download mit nosniff, nie inline. Dazu die
Capability-URL unter /api/attach/<token> fuer externe CalDAV-Clients,
abschaltbar ueber ATTACHMENT_PUBLIC_LINKS=0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neue Tabelle event_attachments samt Ablage-Modul. Dateien liegen unter
DATA_DIR/attachments, geshardet nach einem servergenerierten Namen -- weder
der hochgeladene Dateiname noch die Termin-UID beruehren je einen Pfad, da
die UID bei CalDAV-Terminen aus einem fremden Client stammt.
Der Dateityp wird an den Bytes bestimmt, nie am Content-Type des Clients.
SVG und HTML sind bewusst nicht erlaubt: beide fuehren Skript im Origin der
App aus, wo das Sitzungstoken liegt.
Aufgeraeumt wird an allen fuenf Loeschpfaden (Termin, Kalender, Gruppe,
Benutzer, CalDAV-DELETE), und zwar Zeilen vor dem Commit, Dateien erst
danach -- ein Rollback darf keine Bytes verlieren. Der Sweep als
Sicherheitsnetz ist implementiert, haengt aber noch an keinem Scheduler.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Das Sitzungs-Token trug den Benutzernamen als Subjekt, und get_current_user
suchte danach. Eine Umbenennung machte damit jedes Token auf jedem anderen
Geraet stillschweigend ungueltig — der Nutzer flog ohne Hinweis raus.
Tokens tragen jetzt zusaetzlich uid, die unveraenderliche Zeilen-ID, und die
Aufloesung geht darueber. sub bleibt als Anzeigename erhalten. Tokens ohne
uid werden weiter ueber den Namen aufgeloest, damit vor der Aenderung
ausgestellte Sitzungen gueltig bleiben — mit "Angemeldet bleiben" laufen die
180 Tage.
Fuer SSO war die Zuordnung schon vorher richtig: OIDCIdentity haengt an
(provider_key, sub) des Providers, und bei bekanntem sub wird der lokale
Benutzername nicht angefasst. Ein umbenannter Nutzer in Authentik landet also
unveraendert auf demselben Konto. Vier Tests halten beide Richtungen fest:
Umbenennung beim Provider, lokale Umbenennung mit weiterlaufender Sitzung,
lokale Umbenennung mit erhaltener SSO-Verknuepfung, und die Kompatibilitaet
alter Tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bisher war eine SSO-Anmeldung ohne passendes Konto eine Sackgasse: rote
Fehlermeldung, kein Weg weiter. Die Verknuepfung ging nur ueber
LINK_BY_EMAIL, das der E-Mail-Verwaltung des Providers vertrauen muss.
Jetzt endet der Callback bei einer besitzerlosen Identitaet nicht im Fehler,
sondern parkt sie in einem kurzlebigen signierten HttpOnly-Cookie und leitet
auf /?sso_link=1. Der Login-Screen bittet daraufhin einmalig um das
Calendarr-Passwort; nach erfolgreichem Login loest der Client
POST /api/auth/oidc/link-pending ein und die Identitaet haengt an genau dem
Konto, das sich gerade ausgewiesen hat. Beide Haelften sind damit bewiesen —
das ID-Token haben wir selbst geprueft, das Konto durch das Passwort — und
die Vertrauensannahme ueber die E-Mail entfaellt.
Das greift fuer alle drei Faelle, in denen eine gueltige Identitaet keinen
Besitzer hat: kein Konto vorhanden, Selbstregistrierung aus, oder E-Mail
bereits vergeben.
Ausserdem: unbekannte Fehler-Slugs werden jetzt mit angezeigt statt hinter
"Anmeldung fehlgeschlagen" zu verschwinden — genau die Information, die zur
Diagnose fehlte. Und oidc_token_exchange_failed hat eine eigene Meldung, die
auf Client-Typ und Redirect-URI hinweist, weil das der haeufigste
Konfigurationsfehler ist.
Sechs Tests dazu; Ablauf zusaetzlich im Browser gegen einen echten
OIDC-Stub-Provider durchgespielt: Hinweis statt Fehler, Verknuepfung nach
Passwort-Login, danach Anmeldung allein per SSO.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Authlib wertet "Aufrufer hat eine Nonce uebergeben, das Token traegt keine"
als Mismatch. Das ist fuer den erneuerten Token der mobilen Clients falsch:
ein per Refresh-Token geholtes ID-Token laesst die nonce-Claim regulaer weg,
womit der Hintergrund-Refresh immer an einem 401 gescheitert waere.
Die Nonce wird daher nicht mehr an Authlib durchgereicht, sondern hier
geprueft: vorhandene Nonce muss passen, und der Browser-Flow verlangt sie
zusaetzlich zwingend (require_nonce) — dort erzeugen wir sie selbst, also
muss der Provider sie zurueckspiegeln. Signatur und uebrige Claims bleiben
unveraendert bei Authlib.
Zwei Tests dazu: erneuertes Token ohne Nonce wird angenommen, Browser-Flow
ohne Nonce abgelehnt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Apps duerfen keine Scopes erfinden: ein Scope, den der Provider nicht
kennt, laesst ihn die gesamte Authorization-Anfrage ablehnen. Daher legt
jetzt der Server fest, was die nativen Clients anfragen.
OIDC_<KEY>_MOBILE_SCOPES ist frei setzbar; ohne Angabe sind es die
Web-Scopes plus offline_access, damit Authentik ab 2024.2 ueberhaupt ein
Refresh-Token ausstellt. Der Browser-Flow bleibt unveraendert, der braucht
kein offline_access. Ausgeliefert wird der Wert als mobile_scopes in
/api/auth/oidc/providers, aber nur wenn eine MOBILE_CLIENT_ID konfiguriert
ist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zusaetzlicher Anmeldeweg per OIDC neben dem bestehenden Passwort-Login. Ohne
gesetztes OIDC_PROVIDERS aendert sich nichts: /api/auth/oidc/providers meldet
enabled:false und der Login-Screen bleibt unveraendert.
Backend
- oidc_config.py: Provider-Registry rein aus ENV. Ein zweiter Provider ist
reine Konfiguration (OIDC_PROVIDERS + OIDC_<KEY>_* Block), keine Codeaenderung.
- oidc_client.py: Discovery, JWKS, Token-Tausch, ID-Token-Pruefung ueber
authlib.jose mit fest gepinnter Signatur-Allowlist (nur asymmetrisch, damit
alg:none und Algorithmus-Confusion ausgeschlossen sind).
- oidc_identity.py: Zuordnung ueber (provider_key, sub). Ein E-Mail-Treffer auf
ein bestehendes Konto wird standardmaessig abgelehnt statt verknuepft — sonst
erbt jeder, der im IdP eine beliebige E-Mail setzen kann, das passende Konto.
Verknuepfen ist eine explizite, authentifizierte Aktion. Auto-Provisioning
nur per ENV-Flag, standardmaessig aus, optional mit Domain-Allowlist.
- routers/oidc_router.py: /providers, /start, /callback, /complete, /exchange,
/link, /identities. PKCE S256; Flow-State als kurzlebiges signiertes JWT in
einem HttpOnly-Cookie mit jti==state statt eines prozesslokalen Dicts, damit
der Flow Neustarts und mehrere Worker uebersteht. Das Calendarr-JWT wird ueber
ein Einmal-Cookie uebergeben, nie ueber URL oder Fragment.
- /exchange als Kontrakt fuer die AppAuth-Clients: die App fuehrt den Flow als
Public Client selbst aus und tauscht das ID-Token; kein Secret im Request.
- models.py: neue Tabelle oidc_identities, users.auth_source; Migration in
main.py im dortigen try/except-Stil.
Nebenbefund, mitbehoben: auth.verify_password liess ValueError aus
bcrypt.checkpw durch. Ein nicht wohlgeformter password_hash wurde damit zu
HTTP 500 auf /api/auth/login und bei jedem CalDAV-Basic-Auth. SSO-Nutzer
bekommen einen echten bcrypt-Hash ueber ein zufaelliges, nirgends gespeichertes
Geheimnis; fuer CalDAV nutzen sie App-Passwoerter wie 2FA-Nutzer.
Web
- SSO-Block auf dem Login-Screen, aus /providers befuellt; Buttons sind Links,
weil /start eine Top-Level-Navigation braucht.
- consumeSsoHandoff() laeuft vor den Setup-/Token-Pruefungen in boot() und
raeumt die Query per replaceState ab, bevor es awaited.
- 401-Auto-Logout um die SSO-Pfade erweitert, sonst dreht ein fehlgeschlagener
Handoff eine Reload-Schleife.
- i18n-Keys in de + en (Fallback-Kette deckt die uebrigen acht Sprachen).
- Service Worker laesst /api/auth/oidc/ unangetastet durch.
37 neue Tests (Gesamtsuite 60 gruen): Signaturpruefung mit echten RSA-Tokens,
alg:none, falscher iss/aud/nonce, unbekannte client_id, Provisioning-Policies,
Username-Kollision, Einmaligkeit des Handoff-Cookies, unerreichbarer Provider.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bug 1 — a calendar shared with me stayed visible after unchecking it: the hide
was a one-shot cache filter the server undid on refetch. Add a per-device
hidden set (localStorage 'hiddenLocalCalendars'), honoured in filterEvents
(normal view) and used to drive the checkbox state, so it survives refetch/reload.
Bug 2 — in the group combined view, other members' events showed edit/delete and
403'd on save. The combined endpoint now emits read_only (editable = the group
calendar OR my own events), via a read_only param threaded through
build_local_event_dict/expand_recurring_local. The event popup and edit modal now
treat read_only events as read-only (copy still allowed). Test added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Backend: co-member group_visible calendars now surface in /local/calendars
(owned=false, shared_by=owner, read-only, group_shared) and in the normal
/caldav/events merge (via readable_local_calendar_ids), deduped against
direct shares / group calendars so nothing appears twice.
- Backend: new User.directory_hidden — a user can hide from sharing/group
pickers (/users/directory), while admin user management (/users/) still lists
them. Migration + profile GET/PUT.
- Backend: /groups/{id} members carry shares_calendar so clients can drop
phantom rows for members who share nothing.
- Frontend: reachable "Teilen" button on owned local calendars; share modal is
now a checkbox multi-select of users (checked = shared). Hidden-profile toggle
in Settings → Profile. Group member filter only lists members who actually
share (phantom fix). Calendars shared with me moved to a dedicated read-only
"shared with me" section in the manage table.
- Tests: group_visible propagation, no-share absence, dedup, directory_hidden.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pro Mitglied eine Farbe (group_members.color, auto aus Palette, vom Owner
oder Mitglied selbst änderbar via PUT /groups/{id}/members/{uid}/color).
- Gruppentermin-Farbe = Farbe des Gruppenkalenders.
- API liefert Farben aus: GET /groups & /groups/{id} (member.color,
group_calendar_color), GET /groups/{id}/combined (display_color pro Event)
-> Apps können dieselben Farben anzeigen. Test ergänzt (18 grün).
- Web nutzt display_color; Gruppenkalender im Termin-Editor mit 👥 markiert
(Gruppentermine ohne Gruppenansicht erstellbar); Mitglieder-Farben im
Verwalten-Dialog editierbar. Version v37.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Import: Dedupe doppelter UIDs innerhalb der Datei (Nextcloud exportiert
wiederkehrende Termine als mehrere VEVENTs gleicher UID) -> kein
UNIQUE-constraint-500 mehr; Commit abgesichert. Test ergaenzt (15 gruen).
- Picker (Gruppen-Sichtbarkeit + Mitglieder): als <div>-Zeilen statt <label>,
damit die globale ".form-group label"-Uppercase/Grau-Regel das Layout nicht
mehr zerschiesst. Saubere .pick-row-Optik (Checkbox/Radio links, Name links).
- Einstellungen haben jetzt eigenen URL-State (#...&settings=1): Reload/Cache-
leeren bleibt in den Einstellungen statt zur Kalenderansicht zu springen.
- Version v27.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Neues user_settings.group_visible_calendar_id: jedes Mitglied waehlt EINEN
lokalen Kalender, der in seinen Gruppen sichtbar ist. Die kombinierte
Ansicht ueberlagert nur diesen (statt aller) Kalender je Mitglied + den
Gruppenkalender; private Termine weiter gefiltert. Settings GET/PUT erweitert
(nullbar). Tests angepasst + ergaenzt (14 gruen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Sidebar-Sektion "Gruppen": Liste, Erstellen (Name + Mitglieder-Picker),
Verwalten (Mitglieder hinzufuegen/entfernen), Loeschen.
- Gruppenansicht: laedt /api/groups/{id}/combined fuer den sichtbaren
Bereich; Event-Titel werden mit Besitzer-Initialen bzw. Gruppen-Icon
praefixt; Banner mit "Gruppenansicht verlassen".
- Server: GET /api/local/calendars liefert nun auch Gruppenkalender
(group:true, read_write) fuer Mitglieder, damit sie im Editor waehlbar
sind. Test ergaenzt (13 gruen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>