Compare commits

..
1 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 7a2085b961 feat(mini-cal): ein Punkt je Kalender in dessen Farbe pro Tag
Statt eines einzelnen Punkts in --primary zeigt der Mini-Kalender jetzt fuer
jeden Kalender, der an dem Tag mindestens einen Termin hat, einen Punkt in
dessen Farbe (max. 4, stabil nach erstem Auftreten sortiert). Die Farbquelle
ist dieselbe wie in Monats-/Wochenansicht: ev.color || ev.calendarColor.

Ausserdem laeuft der Mini-Kalender jetzt ueber filterEvents(), damit
ausgeblendete Gruppenmitglieder keinen Punkt mehr erzeugen. Auf dem
"heute"-Kreis bekommen die Punkte einen duennen weissen Ring, damit sie auf
der farbigen Flaeche lesbar bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:01:36 +02:00
52 changed files with 588 additions and 9982 deletions
-87
View File
@@ -106,93 +106,6 @@ Configuration is done via environment variables. The `install.sh` script generat
| `GOOGLE_CLIENT_ID` | — | Google OAuth2 Client ID *(optional)* | | `GOOGLE_CLIENT_ID` | — | Google OAuth2 Client ID *(optional)* |
| `GOOGLE_CLIENT_SECRET` | — | Google OAuth2 Client Secret *(optional)* | | `GOOGLE_CLIENT_SECRET` | — | Google OAuth2 Client Secret *(optional)* |
| `GOOGLE_REDIRECT_URI` | — | e.g. `https://yourdomain.com/api/google/callback` *(optional)* | | `GOOGLE_REDIRECT_URI` | — | e.g. `https://yourdomain.com/api/google/callback` *(optional)* |
| `PUBLIC_BASE_URL` | *(derived)* | Public origin behind a reverse proxy — **required for SSO** |
| `OIDC_PROVIDERS` | — | Comma-separated provider keys, e.g. `authentik` *(optional)* |
Per provider `<KEY>` listed in `OIDC_PROVIDERS` (variable names are uppercased):
| Variable | Default | Description |
|---|---|---|
| `OIDC_<KEY>_ISSUER` | — | **Required.** Issuer URL, e.g. `https://authentik.example.com/application/o/calendarr/` |
| `OIDC_<KEY>_CLIENT_ID` | — | **Required.** Client ID of the web application |
| `OIDC_<KEY>_CLIENT_SECRET` | — | Leave empty for a public client (PKCE only) |
| `OIDC_<KEY>_NAME` | *(key)* | Label on the login button |
| `OIDC_<KEY>_SCOPES` | `openid profile email` | Requested scopes — **must be quoted**, see below |
| `OIDC_<KEY>_MOBILE_CLIENT_ID` | — | Client ID(s) of the native apps, comma-separated |
| `OIDC_<KEY>_MOBILE_ISSUER` | *(issuer)* | Only if mobile uses a second application |
| `OIDC_<KEY>_ALLOW_SIGNUP` | `false` | Create accounts on first SSO login |
| `OIDC_<KEY>_ALLOWED_DOMAINS` | — | Email-domain allowlist for signup |
| `OIDC_<KEY>_LINK_BY_EMAIL` | `false` | Auto-link to an existing account by email |
| `OIDC_<KEY>_USERNAME_CLAIM` | `preferred_username` | Claim used to derive the username |
| `OIDC_<KEY>_REDIRECT_URI` | *(derived)* | Overrides the derived callback URL |
### Single Sign-On (OpenID Connect / Authentik)
Adds an SSO button to the login screen **alongside** password login. With
`OIDC_PROVIDERS` unset, nothing changes — the feature is invisible.
**In Authentik:** create one OAuth2/OpenID provider, client type **Public**
(the web flow uses PKCE, so a secret buys little), with two redirect URIs:
- Web: `https://calendar.example.com/api/auth/oidc/authentik/callback`
- iOS: `com.scarriffleservices.calendarr.ios:/oauth2redirect`
- Android: `com.scarriffle.calendarr:/oauth2redirect`
The mobile apps are separate repositories (`Calendarr-IOS`, `Calendarr-Android`)
and authenticate as **public** clients via AppAuth — PKCE, no client secret.
Set `OIDC_<KEY>_MOBILE_CLIENT_ID` to the client id they use; without it the apps
show no SSO button.
Signing key RS256, scope mappings `openid`, `profile`, `email` — and
**`offline_access` for the mobile apps**. Since Authentik 2024.2 that scope has
to be mapped explicitly; without it no refresh token is issued and the apps
appear to log out every week. Restrict access with an Authentik group binding —
that, not `ALLOWED_DOMAINS`, is the real authorization control.
**In `.env`:**
```bash
PUBLIC_BASE_URL=https://calendar.example.com
OIDC_PROVIDERS=authentik
OIDC_AUTHENTIK_NAME=Authentik
OIDC_AUTHENTIK_ISSUER=https://authentik.example.com/application/o/calendarr/
OIDC_AUTHENTIK_CLIENT_ID=...
OIDC_AUTHENTIK_MOBILE_CLIENT_ID=calendarr-mobile
OIDC_AUTHENTIK_SCOPES="openid profile email"
OIDC_AUTHENTIK_ALLOW_SIGNUP=false
```
> **`install.sh` does not overwrite an existing `.env`.** On an existing
> installation append these lines by hand, then `systemctl restart calendarr`.
> **Quote every value containing a space.** `start.sh` does
> `set -a; source .env; set +a`, so an unquoted
> `OIDC_AUTHENTIK_SCOPES=openid profile email` makes the shell try to run
> `profile` and startup fails.
**Account matching.** An identity is matched on the provider's `sub` claim.
A first SSO login with no matching account does **not** fail. The verified
identity is parked in a short-lived signed cookie and the login screen asks the
user to sign in once with their Calendarr password; the identity is then
attached to exactly that account. Both halves are proven — the ID token by us,
the account by the password — so no trust in the provider'''s email handling is
required.
`LINK_BY_EMAIL=true` links automatically to an existing account with the same
address instead (additionally requiring `email_verified`). It saves one step,
but means anyone who can set an arbitrary email at the identity provider
inherits the matching account, including an admin'''s. Prefer the default.
With `ALLOW_SIGNUP=true`, unknown users get an account on first login. Such
accounts have no usable password; they use **app passwords** for CalDAV, the
same as 2FA users.
**Adding a second provider** is configuration only: append its key to
`OIDC_PROVIDERS`, add the matching `OIDC_<KEY>_*` block, restart. No code change.
**Rollback:** remove `OIDC_PROVIDERS` and restart. Accounts created via SSO
remain and must be removed (or given a password) by an admin.
### Google Calendar Setup ### Google Calendar Setup
-66
View File
@@ -1,66 +0,0 @@
# Theme parameters (Web)
Calendarr's web client lets you customise the colour theme under
**Settings → Darstellung → Farben**. Every colour has its own sync toggle (share
it across your devices or keep it device-local — see
[backend/SETTINGS_SYNC.md](backend/SETTINGS_SYNC.md)).
You can also **export** the current theme to a `<date>_<time>.theme` file and
**import** one later. A `.theme` file is plain JSON:
```json
{
"_format": "calendarr-theme",
"_version": 1,
"_docs": "https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md",
"exported_at": "2026-07-20T14:33:00.000Z",
"settings": {
"primary_color": "#4285F4",
"hover_highlight_color": "#2A2A38",
"...": "..."
}
}
```
A theme may be **partial** — delete any keys you don't want and only the
remaining ones are applied. Importing writes **only the parameters present in
the file** (keys whose sync toggle is on are also pushed to the server; the rest
update this browser only); omitted parameters are left untouched. If the file
contains parameters this version doesn't recognise, you're asked whether to
import the rest anyway.
## Colour parameters
Source of truth for the defaults: `DEFAULT_COLORS` in
[frontend/js/settings-sync.js](frontend/js/settings-sync.js). Each value is a
`#RRGGBB` hex string. Where a default is listed as "derived", leaving the value
untouched reproduces the previous automatic look; setting it overrides that.
| Key | What it colours | Default |
|---|---|---|
| `primary_color` | Primary/brand colour — buttons, links, active states, and the browser favicon/tab colour | `#58B900` |
| `accent_color` | Accent — danger actions, the "now" line, reminders | `#45A148` |
| `today_color` | "Today" accent: the day-number circle and today's labels | `#6FB669` |
| `text_color` | Base text colour (secondary/tertiary text is derived from it) | `#FFFFFF` |
| `bg_color` | App background | `#000000` |
| `surface_color` | Sidebar / top bar / card surfaces (derived from `bg_color` when unset) | `#2B2B2B` |
| `line_color` | Borders and grid lines | `#3B3B3D` |
| `month_divider_color` | The line marking a month change in the scrolling month view | `#95D25E` |
| `month_label_color` | The month abbreviation shown at a month change | `#95D25E` |
| `hover_highlight_color` | General interactive hover — buttons, menu items, list rows | `#2A2A38` |
| `icon_inactive_color` | Sidebar action icons (notification bell *off*, hide/eye, delete/trash, "not editable") in their resting / off / not-hovered state | `#90AA91` |
| `icon_active_color` | The same sidebar action icons when hovered, pressed, or *on* (e.g. notification bell enabled) | `#E8E8F0` |
| `day_hover_color` | Hover background over a calendar day (month / week / quarter / agenda / mini-calendar / date picker) | `#2B382A` |
| `day_selected_color` | The selected day — applied as a subtle tint of this colour | `#88EF9A` |
| `day_bg_color` | Normal (unselected, non-today) day background. Defaults to the app background so days look transparent | `#000000` |
| `today_bg_color` | Today's day-cell background — applied as a subtle tint of this colour | `#477650` |
Notes:
- `day_selected_color` and `today_bg_color` are applied as a low-opacity tint of
the chosen colour (so content stays readable). The colour you pick is the base
hue; the swatch shows the full colour.
- `day_bg_color` defaults to the app background. If you set a custom
`bg_color`, also set `day_bg_color` to match if you want fully transparent days.
- The other clients (iOS / Android) currently ignore the fine-grained element
colours; they are stored and synced by the server but only the web client
renders them.
-63
View File
@@ -1,63 +0,0 @@
# Settings sync contract (Web / iOS / Android)
> For a human-facing description of each theme colour parameter (and the
> `.theme` import/export format), see [../THEME.md](../THEME.md).
Per-setting, cross-device synchronisation of user settings. The **server is the
sole authority** for *which* settings sync; clients must not duplicate that logic.
This document is the shared contract all three clients implement identically.
## Canonical keys & default flags
Source of truth: `DEFAULT_SYNC` in `backend/routers/settings_router.py`. Keys use
the server's snake_case field names.
| Key | Kind | Default sync |
|---|---|---|
| `default_view` | enum | ON |
| `week_start_day` | enum | ON |
| `dim_past_events` | bool | ON |
| `hour_height` | enum(int) | ON |
| `primary_color` `accent_color` `today_color` `text_color` `line_color` `bg_color` `month_divider_color` `month_label_color` | color hex | ON |
| `default_event_duration_minutes` | enum(int) | ON |
| `default_reminder_minutes` | enum(int, null=off) | ON |
| `language` | enum | **OFF** |
| `share_calendar_icon` | icon key | **OFF** |
| `cache_months` | enum(int) | **OFF** |
| `month_view_paged` | bool | **OFF** |
Settings **not** in this list are never synced by this mechanism:
- Account-wide settings (`private_event_visibility`, `group_visible_calendar_id`,
`directory_hidden`) — one value per account, always identical everywhere; they
keep their existing dedicated endpoints/UI, not a sync toggle.
- Platform-exclusive device prefs (e.g. iOS `liquid_glass`) — stay device-local.
- Identity/security, calendar/account management, admin.
## API
- `GET /api/settings/` returns every value **plus** `sync_flags`: a fully-resolved
`{key: bool}` map covering exactly the keys above (stored overrides on top of
`DEFAULT_SYNC`). Clients read this map verbatim — no client-side defaults.
- `PUT /api/settings/` accepts a partial `sync_flags` map (merged account-wide,
unknown keys ignored, untouched flags preserved) and partial value fields
(`exclude_unset`; `text_color`/`line_color`/`bg_color`/… treated as
nullable-reset per `NULLABLE_OVERRIDES`).
## Client rules
Each client keeps a **local copy** of every syncable value (UserDefaults /
DataStore-SharedPreferences / localStorage) so that "not synced" works per device.
1. **On login / launch / foreground:** `GET /api/settings/` → values + `sync_flags`.
2. **Pull:** for each syncable key, if `sync_flags[key]` is ON, adopt the server
value into the local copy; if OFF, keep the local value.
3. **Push (debounced, read-modify-write):** start from the current server snapshot,
overwrite only keys whose flag is ON with the local value, `PUT`. Never push a
key whose flag is OFF.
4. **Toggle a flag ON:** set the flag true **and** push this device's current local
value (it becomes the shared value). **OFF:** set false; keep the local value.
5. **Global "share everything":** set all syncable flags true and push all local
values. Global off: set all false.
The flag map itself is always account-wide and always fetched fresh; it is what a
client consults to decide what to send/receive.
+3 -33
View File
@@ -20,14 +20,7 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
def verify_password(plain: str, hashed: str) -> bool: def verify_password(plain: str, hashed: str) -> bool:
# bcrypt.checkpw raises ValueError when `hashed` is not a well-formed hash return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
# (empty string, a sentinel like "!", a truncated column). Uncaught, that
# became an HTTP 500 on /api/auth/login and on every CalDAV Basic-Auth
# attempt in dav_router. A malformed hash simply means "no password".
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except (ValueError, TypeError):
return False
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
@@ -43,18 +36,6 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_user_token(user: "models.User", expires_delta: Optional[timedelta] = None) -> str:
"""Session token for a user.
``uid`` is the authoritative subject: the immutable row id. ``sub`` keeps
the username for readability and for tokens issued before ``uid`` existed,
but it is not what identifies the account — otherwise renaming a user would
silently invalidate every session on their other devices, and an identity
provider that changes a name would break the mapping.
"""
return create_access_token({"sub": user.username, "uid": user.id}, expires_delta)
def get_current_user( def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> models.User: ) -> models.User:
@@ -65,23 +46,12 @@ def get_current_user(
) )
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
# Other short-lived tokens are signed with the same key (e.g. the OIDC
# flow token); only a real access token may authenticate a request.
if payload.get("typ") not in (None, "access"):
raise exc
uid = payload.get("uid")
username: str = payload.get("sub") username: str = payload.get("sub")
if uid is None and not username: if not username:
raise exc raise exc
except JWTError: except JWTError:
raise exc raise exc
user = db.query(models.User).filter(models.User.username == username).first()
if uid is not None:
user = db.query(models.User).filter(models.User.id == uid).first()
else:
# Legacy token issued before uid existed. "Remember me" runs for 180
# days, so these stay in circulation for a while.
user = db.query(models.User).filter(models.User.username == username).first()
if not user: if not user:
raise exc raise exc
return user return user
-63
View File
@@ -1,63 +0,0 @@
"""Shared helpers for CalDAV publishing of local calendars.
Publishing is opt-in per calendar: a published calendar gets a secret
``dav_token`` and is reachable as a two-way CalDAV collection at
``/dav/{token}/``. ``dav_ctag`` changes on every event write so clients detect
changes; each event carries an ``etag`` that changes on write. Rotating the
token revokes existing subscriptions.
"""
from __future__ import annotations
import os
import secrets
import uuid
def new_token() -> str:
"""A URL-safe, unguessable token used as the CalDAV collection path."""
return secrets.token_urlsafe(24)
def new_tag() -> str:
"""A fresh ctag/etag value."""
return uuid.uuid4().hex
def bump_dav(cal, event=None) -> None:
"""Mark a calendar (and optionally an event) as changed for CalDAV clients.
Safe to call unconditionally on every local-event write — it only refreshes
opaque change tags, so unpublished calendars are unaffected.
"""
if cal is not None:
cal.dav_ctag = new_tag()
if event is not None:
event.etag = new_tag()
def public_base(request) -> str:
"""Public origin (scheme://host) as clients actually reach us.
Behind a reverse proxy (e.g. Nginx Proxy Manager) the app only sees
``http://…:8080`` internally, so honour ``X-Forwarded-Proto/-Host`` and an
optional ``PUBLIC_BASE_URL`` override so published URLs are the real https
ones.
"""
env = os.environ.get("PUBLIC_BASE_URL")
if env:
return env.rstrip("/")
h = request.headers
proto = (h.get("x-forwarded-proto") or request.url.scheme or "http").split(",")[0].strip()
host = (h.get("x-forwarded-host") or h.get("host") or request.url.netloc).split(",")[0].strip()
return f"{proto}://{host}"
def caldav_url(request, token: str) -> str:
"""Absolute per-calendar CalDAV collection URL (secret token, no login)."""
return f"{public_base(request)}/dav/{token}/"
def caldav_login_url(request) -> str:
"""Absolute discovery URL for username/password (Basic Auth) CalDAV access."""
return f"{public_base(request)}/caldav/"
+2 -29
View File
@@ -54,7 +54,7 @@ def private_visibility_for(db: Session, user_id: int) -> str:
# field (title/location/description/creator/calendar name/recurrence) can leak. # field (title/location/description/creator/calendar name/recurrence) can leak.
_BUSY_KEEP = { _BUSY_KEEP = {
"id", "url", "start", "end", "allDay", "calendar_id", "calendarColor", "id", "url", "start", "end", "allDay", "calendar_id", "calendarColor",
"source", "type", "owner", "is_group_event", "display_color", "read_only", "source", "type", "owner", "is_group_event", "display_color",
} }
@@ -102,14 +102,12 @@ def build_local_event_dict(
creator: Optional[dict] = None, creator: Optional[dict] = None,
owner: Optional[dict] = None, owner: Optional[dict] = None,
is_group_event: bool = False, is_group_event: bool = False,
read_only: bool = False,
) -> dict: ) -> dict:
"""Build the unified dict for a single local event (or occurrence). """Build the unified dict for a single local event (or occurrence).
``start``/``end``/``all_day`` override the stored values (used when emitting ``start``/``end``/``all_day`` override the stored values (used when emitting
an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set
by the group combined view. ``read_only`` marks events the requester may not by the group combined view.
edit (someone else's calendar), so clients can hide edit/delete.
""" """
d = { d = {
"id": ev.uid, "id": ev.uid,
@@ -132,31 +130,10 @@ def build_local_event_dict(
"private": bool(ev.is_private), "private": bool(ev.is_private),
"reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()], "reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()],
} }
# Birthday calendars: the server owns the presentation. It flags the event so
# clients can show a cake icon, appends the age computed for THIS occurrence
# (so it stays correct as years pass), and — when the calendar defines a
# "notify N days before" — injects a reminder so the mobile schedulers fire
# it. Web has no notification delivery, so the reminder is display-only there.
if getattr(cal, "is_birthday", False):
d["is_birthday"] = True
display = ev.title
if ev.birth_year:
try:
occ_year = int(str(d["start"])[:4])
age = occ_year - int(ev.birth_year)
if age >= 0:
display = f"{ev.title} ({age})"
except (ValueError, TypeError):
pass
d["display_title"] = display
if not d["reminders"] and cal.birthday_notify_days_before is not None:
d["reminders"] = [int(cal.birthday_notify_days_before) * 1440]
if owner is not None: if owner is not None:
d["owner"] = owner d["owner"] = owner
if is_group_event: if is_group_event:
d["is_group_event"] = True d["is_group_event"] = True
if read_only:
d["read_only"] = True
return d return d
@@ -169,7 +146,6 @@ def expand_recurring_local(
creator: Optional[dict] = None, creator: Optional[dict] = None,
owner: Optional[dict] = None, owner: Optional[dict] = None,
is_group_event: bool = False, is_group_event: bool = False,
read_only: bool = False,
) -> list: ) -> list:
"""Expand a recurring LocalEvent into individual occurrences in the range.""" """Expand a recurring LocalEvent into individual occurrences in the range."""
results = [] results = []
@@ -201,7 +177,6 @@ def expand_recurring_local(
ev, local_cal, ev, local_cal,
start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True, start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True,
creator=creator, owner=owner, is_group_event=is_group_event, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
)) ))
else: else:
ev_start = dt_datetime.fromisoformat(ev_start_str) ev_start = dt_datetime.fromisoformat(ev_start_str)
@@ -228,13 +203,11 @@ def expand_recurring_local(
ev, local_cal, ev, local_cal,
start=occ.isoformat(), end=occ_end.isoformat(), all_day=False, start=occ.isoformat(), end=occ_end.isoformat(), all_day=False,
creator=creator, owner=owner, is_group_event=is_group_event, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
)) ))
except Exception as exc: except Exception as exc:
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
# Fall back to a single event. # Fall back to a single event.
results.append(build_local_event_dict( results.append(build_local_event_dict(
ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event, ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
)) ))
return results return results
+1 -164
View File
@@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine from database import Base, engine
from routers import admin_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, oidc_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) logging.basicConfig(level=logging.INFO)
@@ -225,163 +225,6 @@ def _migrate():
except Exception: except Exception:
pass pass
# Per-setting cross-device sync: value columns for the two newly syncable
# device-local prefs, plus the JSON map of which settings sync.
for col, ddl in (
("cache_months", "ALTER TABLE user_settings ADD COLUMN cache_months INTEGER DEFAULT 3"),
("month_view_paged", "ALTER TABLE user_settings ADD COLUMN month_view_paged BOOLEAN DEFAULT 0"),
("sync_flags", "ALTER TABLE user_settings ADD COLUMN sync_flags TEXT"),
("surface_color", "ALTER TABLE user_settings ADD COLUMN surface_color VARCHAR(7)"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to user_settings", col)
except Exception:
pass
# Fine-grained web element theme colours (all optional overrides).
for col in (
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
"day_hover_color", "day_selected_color",
"day_bg_color", "today_bg_color",
):
try:
conn.execute(text(f"ALTER TABLE user_settings ADD COLUMN {col} VARCHAR(7)"))
conn.commit()
logging.info("Migration: added %s to user_settings", col)
except Exception:
pass
# Allow hiding local (incl. birthday) calendars and iCal subscriptions
# from the sidebar, matching caldav/google/ha.
for tbl in ("local_calendars", "ical_subscriptions"):
try:
conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN sidebar_hidden BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added sidebar_hidden to %s", tbl)
except Exception:
pass
# CalDAV publishing of local calendars (opt-in, secret token URL).
for col, ddl in (
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
("dav_token", "ALTER TABLE local_calendars ADD COLUMN dav_token VARCHAR(64)"),
("dav_ctag", "ALTER TABLE local_calendars ADD COLUMN dav_ctag VARCHAR(32)"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_calendars", col)
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN etag VARCHAR(32)"))
conn.commit()
logging.info("Migration: added etag to local_events")
except Exception:
pass
# Per-recipient colour override for a shared calendar (NULL = owner's colour).
try:
conn.execute(text("ALTER TABLE calendar_shares ADD COLUMN color VARCHAR(16)"))
conn.commit()
logging.info("Migration: added color to calendar_shares")
except Exception:
pass
# Hide a user from sharing/group pickers (admin management still shows them).
try:
conn.execute(text("ALTER TABLE users ADD COLUMN directory_hidden BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added directory_hidden to users")
except Exception:
pass
# Birthday calendars: flag + per-calendar "notify N days before".
for col, ddl in (
("is_birthday", "ALTER TABLE local_calendars ADD COLUMN is_birthday BOOLEAN DEFAULT 0"),
("birthday_notify_days_before", "ALTER TABLE local_calendars ADD COLUMN birthday_notify_days_before INTEGER"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_calendars", col)
except Exception:
pass
# Birthday events: stable external id (Contacts dedup) + birth year.
for col, ddl in (
("external_uid", "ALTER TABLE local_events ADD COLUMN external_uid VARCHAR(255)"),
("birth_year", "ALTER TABLE local_events ADD COLUMN birth_year INTEGER"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_events", col)
except Exception:
pass
# One-time cleanup of duplicate imported events sharing the same
# (calendar_id, external_uid) — e.g. birthdays created repeatedly by an
# older build without idempotent upsert. Keep the earliest row.
# Idempotent: after cleanup there is nothing left to delete.
try:
conn.execute(text(
"DELETE FROM local_events WHERE external_uid IS NOT NULL AND id NOT IN "
"(SELECT MIN(id) FROM local_events WHERE external_uid IS NOT NULL "
"GROUP BY calendar_id, external_uid)"
))
conn.commit()
logging.info("Migration: de-duplicated local_events by external_uid")
except Exception:
pass
# Hard guarantee: the DB itself forbids two events with the same
# external_uid in one calendar (imported entries only; NULLs unconstrained).
try:
conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_local_events_calendar_external "
"ON local_events(calendar_id, external_uid) WHERE external_uid IS NOT NULL"
))
conn.commit()
logging.info("Migration: unique index on (calendar_id, external_uid)")
except Exception:
pass
# ── OpenID Connect single sign-on ────────────────────────────────
# The oidc_identities table itself comes from create_all above; only
# the new users column and the indexes need retrofitting here.
try:
conn.execute(text("ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'local'"))
conn.commit()
logging.info("Migration: added auth_source to users")
except Exception:
pass
try:
conn.execute(text("UPDATE users SET auth_source = 'local' "
"WHERE auth_source IS NULL OR auth_source = ''"))
conn.commit()
except Exception:
pass
try:
conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_oidc_identity_provider_subject "
"ON oidc_identities(provider_key, subject)"
))
conn.commit()
except Exception:
pass
try:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_oidc_identities_user_id "
"ON oidc_identities(user_id)"
))
conn.commit()
except Exception:
pass
_migrate() _migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
@@ -420,21 +263,15 @@ async def add_cache_headers(request: Request, call_next):
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
app.include_router(oidc_router.router, prefix="/api/auth/oidc", tags=["oidc"])
app.include_router(users_router.router, prefix="/api/users", tags=["users"]) app.include_router(users_router.router, prefix="/api/users", tags=["users"])
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"]) app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"]) 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(local_router.router, prefix="/api/local", tags=["local"])
app.include_router(birthdays_router.router, prefix="/api/birthdays", tags=["birthdays"])
app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"]) 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(ical_router.router, prefix="/api/ical", tags=["ical"])
app.include_router(google_router.router, prefix="/api/google", tags=["google"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"])
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"]) app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"])
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
app.include_router(dav_router.router, tags=["dav"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
-156
View File
@@ -1,5 +1,3 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from database import Base from database import Base
@@ -19,12 +17,6 @@ class User(Base):
avatar_filename = Column(String(255), nullable=True) avatar_filename = Column(String(255), nullable=True)
totp_secret = Column(String(32), nullable=True) totp_secret = Column(String(32), nullable=True)
totp_enabled = Column(Boolean, default=False) totp_enabled = Column(Boolean, default=False)
# When true, the user is hidden from sharing/group picker directories
# (/users/directory). Admin user management (/users/) still shows them.
directory_hidden = Column(Boolean, default=False, nullable=False)
# 'local' (password login) or 'sso' (provisioned via OpenID Connect).
# SSO users have a random, unusable password_hash — see oidc_identity.
auth_source = Column(String(20), default="local")
caldav_accounts = relationship( caldav_accounts = relationship(
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan" "CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
@@ -44,9 +36,6 @@ class User(Base):
homeassistant_accounts = relationship( homeassistant_accounts = relationship(
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan" "HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
) )
oidc_identities = relationship(
"OIDCIdentity", back_populates="user", cascade="all, delete-orphan"
)
@property @property
def display(self) -> str: def display(self) -> str:
@@ -108,18 +97,6 @@ class UserSettings(Base):
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast) 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) 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) bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
# Surface/sidebar/topbar colour (web sidebar + top bar, iOS top bar).
# NULL = derive from bg_color. Device-local by default (not synced).
surface_color = Column(String(7), nullable=True)
# Fine-grained web element colours (all optional overrides; NULL = derive the
# previous default). See frontend THEME.md for what each one paints.
hover_highlight_color = Column(String(7), nullable=True) # general interactive hover (buttons, rows)
icon_inactive_color = Column(String(7), nullable=True) # sidebar action icons: resting/off state
icon_active_color = Column(String(7), nullable=True) # sidebar action icons: hovered/on state
day_hover_color = Column(String(7), nullable=True) # calendar day-cell hover
day_selected_color = Column(String(7), nullable=True) # selected day background
day_bg_color = Column(String(7), nullable=True) # normal day background
today_bg_color = Column(String(7), nullable=True) # today's day-cell background
# How this user's private events appear to other group members: # How this user's private events appear to other group members:
# 'hidden' = invisible, 'busy' = anonymous busy block (default). # 'hidden' = invisible, 'busy' = anonymous busy block (default).
private_event_visibility = Column(String(10), default="busy") private_event_visibility = Column(String(10), default="busy")
@@ -133,54 +110,10 @@ class UserSettings(Base):
default_event_duration_minutes = Column(Integer, default=60) default_event_duration_minutes = Column(Integer, default=60)
# Icon key (from GROUP_ICON_KEYS) shown next to calendars this user shares with groups. # Icon key (from GROUP_ICON_KEYS) shown next to calendars this user shares with groups.
share_calendar_icon = Column(String(16), nullable=True) share_calendar_icon = Column(String(16), nullable=True)
# How many months around the visible range clients preload/cache. Device-local
# by default (only shared when its sync flag is on).
cache_months = Column(Integer, default=3)
# Whether the month view uses horizontal paging (swipe) instead of a vertical
# scroll feed. Device-local by default (only shared when its sync flag is on).
month_view_paged = Column(Boolean, default=False)
# Per-setting cross-device sync overrides as JSON {key: bool}. Absent keys fall
# back to settings_router.DEFAULT_SYNC. Account-wide (one map per user); it is
# the single authority for which settings each client sends/fetches.
sync_flags = Column(Text, nullable=True)
user = relationship("User", back_populates="settings") user = relationship("User", back_populates="settings")
class InstanceSettings(Base):
"""Server-wide (singleton, id=1) branding + default theme set by an admin.
Applies to everyone; a user's own settings still override the default theme."""
__tablename__ = "instance_settings"
id = Column(Integer, primary_key=True) # always 1
# JSON {colorKey: "#RRGGBB"} — the instance default theme. Empty/NULL = use the
# client's built-in defaults. A user's own colour wins over this.
default_theme = Column(Text, nullable=True)
# Uploaded branding files (stored under DATA_DIR/branding). NULL = use bundled.
logo_filename = Column(String(255), nullable=True)
favicon_filename = Column(String(255), nullable=True)
class AppPassword(Base):
"""Per-device app-specific password for CalDAV (Basic Auth).
Keeps MFA intact: accounts with 2FA can't use their normal password over
CalDAV (clients can't send a TOTP code), so they authenticate with one of
these revocable app passwords instead. Only the bcrypt hash is stored.
"""
__tablename__ = "app_passwords"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
label = Column(String(100), nullable=False)
password_hash = Column(String(255), nullable=False)
created_at = Column(String(50), nullable=True)
last_used_at = Column(String(50), nullable=True)
user = relationship("User")
class LocalCalendar(Base): class LocalCalendar(Base):
__tablename__ = "local_calendars" __tablename__ = "local_calendars"
@@ -189,22 +122,8 @@ class LocalCalendar(Base):
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
color = Column(String(7), default="#34a853") color = Column(String(7), default="#34a853")
enabled = Column(Boolean, default=True) enabled = Column(Boolean, default=True)
# Hidden from the sidebar calendar list (still owned/kept; just not shown).
sidebar_hidden = Column(Boolean, default=False, nullable=False)
# Whether events of this calendar generate reminders/notifications on clients. # Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False) reminders_enabled = Column(Boolean, default=True, nullable=False)
# CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV
# collection reachable via a secret token URL. dav_ctag changes on every
# event write so clients detect changes; rotating the token revokes access.
caldav_published = Column(Boolean, default=False, nullable=False)
dav_token = Column(String(64), nullable=True, unique=True)
dav_ctag = Column(String(32), nullable=True)
# Birthday calendar: events are all-day, yearly-recurring; the server adds the
# age suffix ("Anna (30)") and an is_birthday flag so clients show a cake icon.
is_birthday = Column(Boolean, default=False, nullable=False)
# How many days before a birthday to remind (0 = on the day). NULL = no reminder.
# Injected as an event reminder on read so the mobile schedulers fire it.
birthday_notify_days_before = Column(Integer, nullable=True)
user = relationship("User", back_populates="local_calendars") user = relationship("User", back_populates="local_calendars")
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan") events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
@@ -233,13 +152,6 @@ class LocalEvent(Base):
creator_name_external = Column(Text, nullable=True) creator_name_external = Column(Text, nullable=True)
# Private events are filtered for other group members per their visibility setting. # Private events are filtered for other group members per their visibility setting.
is_private = Column(Boolean, default=False) is_private = Column(Boolean, default=False)
# CalDAV entity tag — changes on every write so CalDAV clients detect updates.
etag = Column(String(32), nullable=True)
# Stable external identity for imported entries (e.g. a Contacts birthday keyed
# by "contact:<id>"), so a re-sync can mirror the address book without dupes.
external_uid = Column(String(255), nullable=True, index=True)
# Birth year for birthday events; NULL = year unknown (no age shown).
birth_year = Column(Integer, nullable=True)
calendar = relationship("LocalCalendar", back_populates="events") calendar = relationship("LocalCalendar", back_populates="events")
creator = relationship("User") creator = relationship("User")
@@ -254,8 +166,6 @@ class ICalSubscription(Base):
url = Column(String(1000), nullable=False) url = Column(String(1000), nullable=False)
color = Column(String(7), default="#46bdc6") color = Column(String(7), default="#46bdc6")
enabled = Column(Boolean, default=True) enabled = Column(Boolean, default=True)
# Hidden from the sidebar calendar list (still subscribed; just not shown).
sidebar_hidden = Column(Boolean, default=False, nullable=False)
# Whether events of this subscription generate reminders/notifications on clients. # Whether events of this subscription generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False) reminders_enabled = Column(Boolean, default=True, nullable=False)
refresh_minutes = Column(Integer, default=60) refresh_minutes = Column(Integer, default=60)
@@ -372,22 +282,6 @@ class CalendarShare(Base):
user = relationship("User") user = relationship("User")
class CalendarColorPref(Base):
"""A user's personal colour for a calendar they don't own — works for any
way a foreign calendar becomes visible (direct share, group calendar, or a
co-member's group-visible calendar). NULL/absent = the owner's colour."""
__tablename__ = "calendar_color_prefs"
__table_args__ = (
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_color_pref"),
)
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)
color = Column(String(16), nullable=False)
class Group(Base): class Group(Base):
__tablename__ = "groups" __tablename__ = "groups"
@@ -423,26 +317,6 @@ class GroupMember(Base):
user = relationship("User") user = relationship("User")
class BirthdaySyncDevice(Base):
"""A device that has synced Contacts birthdays into the user's birthday
calendar. Powers the web "birthdays come from these devices" list. One row
per (user, device); the client sends a stable device_id + human name."""
__tablename__ = "birthday_sync_devices"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_birthday_sync_device"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
device_id = Column(String(64), nullable=False)
device_name = Column(String(120), nullable=False)
last_sync = Column(String(50), nullable=True) # ISO 8601
count = Column(Integer, default=0)
user = relationship("User")
class GroupCalendar(Base): class GroupCalendar(Base):
"""1:1 link between a group and its shared local calendar.""" """1:1 link between a group and its shared local calendar."""
@@ -458,33 +332,3 @@ class GroupCalendar(Base):
group = relationship("Group", back_populates="group_calendar") group = relationship("Group", back_populates="group_calendar")
calendar = relationship("LocalCalendar") calendar = relationship("LocalCalendar")
class OIDCIdentity(Base):
"""An external OpenID Connect identity linked to a Calendarr user.
A separate table rather than columns on ``users`` so one person can hold
identities at several providers (the multi-provider requirement).
Keyed on ``(provider_key, subject)`` and NOT on ``(issuer, subject)``: if
the web and mobile clients are registered as two Authentik *applications*
they have different issuer URLs, which would otherwise fork one human into
two accounts. ``issuer`` is kept for auditing.
"""
__tablename__ = "oidc_identities"
__table_args__ = (
UniqueConstraint("provider_key", "subject", name="ux_oidc_identity_provider_subject"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# The configured provider key, e.g. "authentik".
provider_key = Column(String(50), nullable=False)
issuer = Column(String(500), nullable=False)
subject = Column(String(255), nullable=False)
email = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
last_login_at = Column(DateTime, nullable=True)
user = relationship("User", back_populates="oidc_identities")
-291
View File
@@ -1,291 +0,0 @@
"""OpenID Connect protocol client: discovery, JWKS, token exchange, validation.
HTTP goes through ``requests`` (already a dependency, and what the Google and
Home Assistant routers use); everything cryptographic goes through Authlib.
We deliberately use only ``authlib.jose`` and not
``authlib.integrations.starlette_client``, which would drag in
``SessionMiddleware``/``itsdangerous`` and a second authentication surface
alongside the existing bearer JWT.
Unlike ``homeassistant_router``, every request here verifies TLS.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import time
from typing import Optional
import requests as http_requests
from authlib.jose import JsonWebKey, JsonWebToken
from authlib.oidc.core import CodeIDToken
from oidc_config import OIDCProvider, accepted_issuers
logger = logging.getLogger(__name__)
HTTP_TIMEOUT = 10
DISCOVERY_TTL = 3600
JWKS_TTL = 3600
# Never refetch the JWKS more than once a minute, so an unknown `kid` cannot be
# used to hammer the identity provider.
JWKS_MIN_REFETCH = 60
# Signature algorithms we accept. This allow-list is the single most important
# line in this module: the module-level ``authlib.jose.jwt`` helper honours
# whatever `alg` the token header declares, which is the classic "alg: none" /
# algorithm-confusion hole. Asymmetric only — an HMAC algorithm here would let
# anyone who can read the (public) JWKS mint their own tokens.
SIGNING_ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
"ES256", "ES384", "ES512"]
_JWT = JsonWebToken(SIGNING_ALGORITHMS)
_AT_HASH_DIGESTS = {"256": hashlib.sha256, "384": hashlib.sha384, "512": hashlib.sha512}
class OIDCError(Exception):
"""Protocol-level failure. ``slug`` is a stable, user-safe error code."""
def __init__(self, slug: str, detail: str = ""):
super().__init__(detail or slug)
self.slug = slug
self.detail = detail or slug
# ── caches ───────────────────────────────────────────────────────────────────
_discovery_cache: dict = {} # provider key -> (fetched_at, document)
_jwks_cache: dict = {} # provider key -> (fetched_at, key_set)
def reset_cache() -> None:
"""Drop discovery and JWKS caches — used by tests."""
_discovery_cache.clear()
_jwks_cache.clear()
def _http_get(url: str, timeout: int = HTTP_TIMEOUT) -> dict:
"""The single network seam for GETs, so tests can stub one function.
Transport failures (DNS, timeout, TLS) must surface as OIDCError like any
other protocol failure — otherwise an unreachable provider turns into an
HTTP 500 instead of a clean "try again" on the login screen.
"""
try:
resp = http_requests.get(url, timeout=timeout)
except http_requests.RequestException as e:
raise OIDCError("oidc_provider_unreachable", f"GET {url}: {e}")
if resp.status_code != 200:
raise OIDCError("oidc_discovery_failed", f"GET {url} -> {resp.status_code}")
try:
return resp.json()
except ValueError:
raise OIDCError("oidc_discovery_failed", f"GET {url}: response is not JSON")
def discovery_url(provider: OIDCProvider) -> str:
return provider.issuer.rstrip("/") + "/.well-known/openid-configuration"
def discover(provider: OIDCProvider, force: bool = False) -> dict:
"""Fetch and cache the provider's OpenID configuration document."""
cached = _discovery_cache.get(provider.key)
if cached and not force and (time.time() - cached[0]) < DISCOVERY_TTL:
return cached[1]
doc = _http_get(discovery_url(provider))
# Mix-up defence: the document must claim the issuer we configured.
doc_issuer = (doc.get("issuer") or "").rstrip("/")
if doc_issuer and doc_issuer != provider.issuer.rstrip("/"):
raise OIDCError(
"oidc_issuer_mismatch",
f"discovery issuer {doc_issuer!r} != configured {provider.issuer!r}",
)
_discovery_cache[provider.key] = (time.time(), doc)
return doc
def _endpoint(provider: OIDCProvider, name: str) -> str:
url = discover(provider).get(name)
if not url:
raise OIDCError("oidc_discovery_incomplete", f"{name} missing from discovery")
return url
def authorization_endpoint(provider: OIDCProvider) -> str:
return _endpoint(provider, "authorization_endpoint")
def token_endpoint(provider: OIDCProvider) -> str:
return _endpoint(provider, "token_endpoint")
def jwks_key_set(provider: OIDCProvider, force: bool = False):
"""Fetch and cache the provider's JWKS as an Authlib key set."""
cached = _jwks_cache.get(provider.key)
now = time.time()
if cached and not force and (now - cached[0]) < JWKS_TTL:
return cached[1]
if cached and force and (now - cached[0]) < JWKS_MIN_REFETCH:
# Rate-limit forced refetches so unknown kids can't be used as a DoS.
return cached[1]
jwks = _http_get(_endpoint(provider, "jwks_uri"))
key_set = JsonWebKey.import_key_set(jwks)
_jwks_cache[provider.key] = (now, key_set)
return key_set
def exchange_code(provider: OIDCProvider, code: str, redirect_uri: str,
code_verifier: str) -> dict:
"""Trade an authorization code for tokens (always with PKCE)."""
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": provider.client_id,
"code_verifier": code_verifier,
}
# Confidential client -> client_secret_post. Public client -> PKCE only.
if provider.client_secret:
data["client_secret"] = provider.client_secret
try:
resp = http_requests.post(
token_endpoint(provider), data=data, timeout=HTTP_TIMEOUT,
headers={"Accept": "application/json"},
)
except http_requests.RequestException as e:
raise OIDCError("oidc_provider_unreachable", f"token endpoint: {e}")
if resp.status_code != 200:
logger.warning("OIDC token exchange failed (%s): %s",
resp.status_code, resp.text[:300])
raise OIDCError("oidc_token_exchange_failed")
try:
tokens = resp.json()
except ValueError:
raise OIDCError("oidc_token_exchange_failed", "token response is not JSON")
if not tokens.get("id_token"):
raise OIDCError("oidc_no_id_token")
return tokens
def fetch_userinfo(provider: OIDCProvider, access_token: str) -> dict:
"""Userinfo lookup — only used when the ID token carries no email."""
try:
url = _endpoint(provider, "userinfo_endpoint")
resp = http_requests.get(
url, timeout=HTTP_TIMEOUT,
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code != 200:
return {}
return resp.json()
except Exception:
return {}
def _verify_at_hash(claims, access_token: str) -> None:
"""Bind the ID token to its access token (OIDC core 3.1.3.6)."""
at_hash = claims.get("at_hash")
if not at_hash or not access_token:
return
alg = (claims.header or {}).get("alg", "")
digest_fn = _AT_HASH_DIGESTS.get(alg[-3:])
if digest_fn is None:
return
digest = digest_fn(access_token.encode("ascii")).digest()
expected = base64.urlsafe_b64encode(digest[: len(digest) // 2]).rstrip(b"=").decode()
if not hmac.compare_digest(expected, at_hash):
raise OIDCError("oidc_at_hash_mismatch")
def validate_id_token(provider: OIDCProvider, id_token: str, *,
expected_audience: str,
nonce: Optional[str] = None,
require_nonce: bool = False,
access_token: Optional[str] = None) -> dict:
"""Verify an ID token's signature and claims. Returns the claim dict.
``nonce`` is the value the client bound into the flow. When
``require_nonce`` is set the token must carry a matching one — that is the
browser flow, where we generated the nonce ourselves and the provider is
obliged to echo it. Native clients also pass a nonce, but a token they
renewed with a refresh token legitimately no longer carries the claim, so
there the check applies only if the claim is present.
Raises :class:`OIDCError` with a stable slug on any failure.
"""
issuers = accepted_issuers(provider)
def _decode(force_jwks: bool):
key_set = jwks_key_set(provider, force=force_jwks)
return _JWT.decode(
id_token, key_set, claims_cls=CodeIDToken,
claims_options={
"iss": {"essential": True, "values": sorted(issuers)},
"aud": {"essential": True, "values": [expected_audience]},
"exp": {"essential": True},
"sub": {"essential": True},
},
# Nonce deliberately omitted: Authlib treats "caller supplied a
# nonce but the token has none" as a mismatch, which breaks
# refreshed tokens. The policy is enforced explicitly below.
claims_params={"client_id": expected_audience,
"access_token": access_token},
)
try:
claims = _decode(force_jwks=False)
except OIDCError:
raise
except Exception as first_error:
# The provider may have rotated its signing key since we cached the
# JWKS. One rate-limited refetch avoids needing a restart.
try:
claims = _decode(force_jwks=True)
except Exception:
logger.info("OIDC id_token signature/decode failed: %s", first_error)
raise OIDCError("oidc_invalid_token")
try:
claims.validate(leeway=60)
except Exception as e:
logger.info("OIDC id_token claim validation failed: %s", e)
raise OIDCError("oidc_invalid_token")
# Explicit re-checks. Authlib already covers these, but stating them here
# makes the security contract readable and testable without depending on
# Authlib's internal claim-validation details.
if (claims.get("iss") or "").rstrip("/") not in {i.rstrip("/") for i in issuers}:
raise OIDCError("oidc_bad_issuer")
aud = claims.get("aud")
aud_list = aud if isinstance(aud, list) else [aud]
if expected_audience not in aud_list:
raise OIDCError("oidc_bad_audience")
# With a multi-valued `aud`, `azp` must name the client the token is for.
if len(aud_list) > 1:
azp = claims.get("azp")
if azp and azp != expected_audience:
raise OIDCError("oidc_bad_azp")
if not claims.get("sub"):
raise OIDCError("oidc_no_subject")
token_nonce = claims.get("nonce")
if require_nonce and not token_nonce:
raise OIDCError("oidc_nonce_missing")
if nonce is not None and token_nonce is not None and token_nonce != nonce:
raise OIDCError("oidc_nonce_mismatch")
if access_token:
_verify_at_hash(claims, access_token)
return dict(claims)
-192
View File
@@ -1,192 +0,0 @@
"""OpenID Connect provider configuration, read from environment variables.
A provider is declared by listing its key in ``OIDC_PROVIDERS`` and adding a
block of ``OIDC_<KEY>_<FIELD>`` variables::
OIDC_PROVIDERS=authentik
OIDC_AUTHENTIK_ISSUER=https://authentik.example.com/application/o/calendarr/
OIDC_AUTHENTIK_CLIENT_ID=...
OIDC_AUTHENTIK_CLIENT_SECRET=...
Adding a second provider is therefore a pure configuration change — append the
key to ``OIDC_PROVIDERS``, add its block, restart. No code change.
Everything here is read lazily via :func:`get_providers` rather than at import
time, because ``backend/tests/conftest.py`` sets the environment *after* the
app modules are imported.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from typing import Optional
logger = logging.getLogger(__name__)
# Values accepted as "true" in the boolean env vars.
_TRUE = {"1", "true", "yes", "on"}
DEFAULT_SCOPES = "openid profile email"
@dataclass(frozen=True)
class OIDCProvider:
"""One configured identity provider."""
key: str # url-safe, lowercase, e.g. "authentik"
name: str # display label, e.g. "Authentik"
issuer: str # the exact `iss` we accept
client_id: str # web/confidential client
client_secret: str = "" # empty => public client, PKCE only
scopes: str = DEFAULT_SCOPES
redirect_uri: str = "" # empty => derived from public_base()
mobile_client_ids: tuple = () # public native clients (AppAuth)
mobile_scopes: str = "" # scopes the apps request (see below)
mobile_issuer: str = "" # only if mobile uses a 2nd application
allow_signup: bool = False # just-in-time provisioning
allowed_domains: tuple = () # email domain allowlist for signup
link_by_email: bool = False # auto-link to an existing account
username_claim: str = "preferred_username"
icon: str = ""
@property
def is_public(self) -> bool:
"""True when no client secret is configured (PKCE-only web client)."""
return not self.client_secret
def _env(key: str, field_name: str, default: str = "") -> str:
return os.environ.get(f"OIDC_{key.upper()}_{field_name}", default).strip()
def _env_bool(key: str, field_name: str, default: bool = False) -> bool:
raw = _env(key, field_name)
if not raw:
return default
return raw.lower() in _TRUE
def _env_list(key: str, field_name: str) -> tuple:
raw = _env(key, field_name)
return tuple(p.strip().lower() for p in raw.split(",") if p.strip())
def _build_provider(key: str) -> Optional[OIDCProvider]:
"""Build one provider from the environment, or None when unusable."""
issuer = _env(key, "ISSUER")
client_id = _env(key, "CLIENT_ID")
# A half-configured provider must never half-work: dropping it keeps the
# login screen honest (no button) instead of failing mid-flow.
if not issuer or not client_id:
logger.warning(
"OIDC provider %r ignored: OIDC_%s_ISSUER and OIDC_%s_CLIENT_ID are both required",
key, key.upper(), key.upper(),
)
return None
mobile_ids = tuple(
p.strip() for p in _env(key, "MOBILE_CLIENT_ID").split(",") if p.strip()
)
scopes = _env(key, "SCOPES") or DEFAULT_SCOPES
# The native apps need a refresh token, which requires `offline_access`.
# Since Authentik 2024.2 that scope must be mapped explicitly, and some
# other providers reject an unknown scope outright — so it is the server,
# not the app, that decides. Overridable per provider without a code change.
mobile_scopes = _env(key, "MOBILE_SCOPES")
if not mobile_scopes:
wanted = scopes.split()
if "offline_access" not in wanted:
wanted.append("offline_access")
mobile_scopes = " ".join(wanted)
return OIDCProvider(
key=key,
name=_env(key, "NAME") or key.capitalize(),
issuer=issuer,
client_id=client_id,
client_secret=_env(key, "CLIENT_SECRET"),
scopes=scopes,
mobile_scopes=mobile_scopes,
redirect_uri=_env(key, "REDIRECT_URI"),
mobile_client_ids=mobile_ids,
mobile_issuer=_env(key, "MOBILE_ISSUER"),
allow_signup=_env_bool(key, "ALLOW_SIGNUP"),
allowed_domains=_env_list(key, "ALLOWED_DOMAINS"),
link_by_email=_env_bool(key, "LINK_BY_EMAIL"),
username_claim=_env(key, "USERNAME_CLAIM") or "preferred_username",
icon=_env(key, "ICON"),
)
def load_providers() -> dict:
"""Parse the environment fresh. Prefer :func:`get_providers` (memoised)."""
keys = [
k.strip().lower()
for k in os.environ.get("OIDC_PROVIDERS", "").split(",")
if k.strip()
]
providers = {}
for key in keys:
if key in providers:
continue
provider = _build_provider(key)
if provider is not None:
providers[key] = provider
return providers
_cache: Optional[dict] = None
def get_providers(refresh: bool = False) -> dict:
"""All usable providers, keyed by provider key. Memoised."""
global _cache
if _cache is None or refresh:
_cache = load_providers()
return _cache
def get_provider(key: str) -> Optional[OIDCProvider]:
return get_providers().get((key or "").strip().lower())
def is_enabled() -> bool:
return bool(get_providers())
def reset_cache() -> None:
"""Drop the memoised providers — used by tests after changing the env."""
global _cache
_cache = None
def _normalise_issuer(issuer: str) -> str:
return (issuer or "").rstrip("/")
def accepted_issuers(provider: OIDCProvider) -> set:
"""Issuer values we accept in an `iss` claim.
Authentik's issuer ends in a slash while some tooling drops it, so accept
both spellings. A second Authentik *application* for the mobile client has
its own issuer URL, hence ``mobile_issuer``.
"""
out = set()
for raw in (provider.issuer, provider.mobile_issuer):
if not raw:
continue
bare = _normalise_issuer(raw)
out.add(bare)
out.add(bare + "/")
return out
def accepted_audiences(provider: OIDCProvider) -> set:
"""Client ids that may appear in an `aud` claim for this provider."""
out = {provider.client_id}
out.update(provider.mobile_client_ids)
return {a for a in out if a}
-239
View File
@@ -1,239 +0,0 @@
"""Mapping a verified OIDC identity onto a Calendarr user account.
The security-relevant decisions live here:
* ``(provider_key, sub)`` is the only automatic match. ``sub`` is the one claim
an identity provider guarantees to be stable and unique; email and username
are not.
* Matching a verified email onto an *existing* local account is refused by
default. Anyone who can set an arbitrary email on an IdP account would
otherwise inherit the matching Calendarr account — including an admin's.
Linking is an explicit, authenticated action instead.
* Just-in-time provisioning is off unless the provider enables it.
"""
from __future__ import annotations
import hashlib
import logging
import re
import secrets
import unicodedata
from datetime import datetime
from typing import Optional, Tuple
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
import models
from auth import get_password_hash
from oidc_config import OIDCProvider
logger = logging.getLogger(__name__)
USERNAME_MAX = 50
_USERNAME_STRIP = re.compile(r"[^a-z0-9._-]")
class OIDCPolicyError(Exception):
"""A refusal that is the user's/admin's to resolve, not a protocol fault.
``slug`` maps to a translated message on the login screen.
"""
def __init__(self, slug: str, detail: str = ""):
super().__init__(detail or slug)
self.slug = slug
self.detail = detail or slug
def claim_email(claims: dict) -> str:
return (claims.get("email") or "").strip().lower()
def _normalise_username(raw: str) -> str:
"""Fold a claim value into the shape `users.username` requires."""
if not raw:
return ""
folded = unicodedata.normalize("NFKD", raw)
folded = folded.encode("ascii", "ignore").decode("ascii").lower()
folded = _USERNAME_STRIP.sub("", folded)
return folded.strip("._-")[:USERNAME_MAX]
def _username_taken(db: Session, name: str) -> bool:
return db.query(models.User).filter(
func.lower(models.User.username) == name.lower()
).first() is not None
def derive_username(db: Session, claims: dict, provider: OIDCProvider) -> str:
"""Pick a free, valid username for a newly provisioned SSO user.
A collision here is a *different human* who happens to share a
preferred_username, so we never reuse an existing name — that would be
instant account takeover.
"""
candidates = [
claims.get(provider.username_claim),
claim_email(claims).split("@")[0] if claim_email(claims) else "",
"u" + hashlib.sha256((claims.get("sub") or "").encode()).hexdigest()[:12],
]
base = ""
for candidate in candidates:
base = _normalise_username(candidate or "")
if base:
break
if not base:
base = "u" + secrets.token_hex(6)
if not _username_taken(db, base):
return base
for suffix in range(2, 51):
tail = f"-{suffix}"
name = base[: USERNAME_MAX - len(tail)] + tail
if not _username_taken(db, name):
return name
# Pathological case: fall back to something effectively unique.
tail = "-" + secrets.token_hex(5)
return base[: USERNAME_MAX - len(tail)] + tail
def _create_user(db: Session, provider: OIDCProvider, claims: dict,
email: str) -> models.User:
"""Provision a new account. Mirrors users_router.create_user."""
# `users.password_hash` is NOT NULL and `auth.verify_password` feeds it
# straight to bcrypt.checkpw, which raises ValueError on anything that is
# not a real hash — that surfaces as HTTP 500 on /api/auth/login and on
# every CalDAV Basic-Auth attempt. So store a real bcrypt hash of a random
# secret that is never written down anywhere: unusable, but well-formed.
user = models.User(
username=derive_username(db, claims, provider),
display_name=(claims.get("name") or "").strip()[:100] or None,
email=email or None,
password_hash=get_password_hash(secrets.token_urlsafe(48)),
is_admin=False,
auth_source="sso",
)
db.add(user)
db.flush()
# Without this row /api/settings returns 500 for the new user.
db.add(models.UserSettings(user_id=user.id))
return user
def _attach_identity(db: Session, user: models.User, provider: OIDCProvider,
claims: dict, email: str) -> models.OIDCIdentity:
identity = models.OIDCIdentity(
user_id=user.id,
provider_key=provider.key,
issuer=claims.get("iss") or provider.issuer,
subject=claims["sub"],
email=email or None,
last_login_at=datetime.utcnow(),
)
db.add(identity)
return identity
def find_identity(db: Session, provider: OIDCProvider,
subject: str) -> Optional[models.OIDCIdentity]:
return db.query(models.OIDCIdentity).filter(
models.OIDCIdentity.provider_key == provider.key,
models.OIDCIdentity.subject == subject,
).first()
def link_identity(db: Session, user: models.User, provider: OIDCProvider,
claims: dict) -> models.OIDCIdentity:
"""Attach an SSO identity to an already-authenticated account."""
subject = claims.get("sub") or ""
if not subject:
raise OIDCPolicyError("oidc_no_subject")
existing = find_identity(db, provider, subject)
if existing is not None:
if existing.user_id != user.id:
raise OIDCPolicyError("oidc_identity_taken")
existing.last_login_at = datetime.utcnow()
db.commit()
return existing
identity = _attach_identity(db, user, provider, claims, claim_email(claims))
db.commit()
return identity
def resolve_user(db: Session, provider: OIDCProvider,
claims: dict) -> Tuple[models.User, str]:
"""Map verified claims onto a user.
Returns ``(user, action)`` where action is ``login``, ``linked`` or
``created``. Raises :class:`OIDCPolicyError` for every refusal.
"""
subject = claims.get("sub") or ""
if not subject:
raise OIDCPolicyError("oidc_no_subject")
email = claim_email(claims)
# 1. Known identity — the only automatic path.
identity = find_identity(db, provider, subject)
if identity is not None:
identity.last_login_at = datetime.utcnow()
if email:
identity.email = email
db.commit()
return identity.user, "login"
# 2. An existing local account with the same email.
existing = None
if email:
existing = db.query(models.User).filter(
func.lower(models.User.email) == email
).first()
if existing is not None:
if not provider.link_by_email:
# The account-takeover guard. Linking must be explicit.
raise OIDCPolicyError("oidc_account_not_linked")
if claims.get("email_verified") is not True:
raise OIDCPolicyError("oidc_email_not_verified")
logger.warning(
"OIDC: linking provider=%s sub=%s to existing user id=%s by email %s",
provider.key, subject, existing.id, email,
)
_attach_identity(db, existing, provider, claims, email)
db.commit()
return existing, "linked"
# 3. Just-in-time provisioning.
if not provider.allow_signup:
raise OIDCPolicyError("oidc_signup_disabled")
if not email:
raise OIDCPolicyError("oidc_no_email")
if provider.allowed_domains:
domain = email.rsplit("@", 1)[-1]
if domain not in provider.allowed_domains:
raise OIDCPolicyError("oidc_domain_not_allowed")
try:
user = _create_user(db, provider, claims, email)
_attach_identity(db, user, provider, claims, email)
db.commit()
except IntegrityError:
# Race between the uniqueness check and the insert — retry once.
db.rollback()
try:
user = _create_user(db, provider, claims, email)
_attach_identity(db, user, provider, claims, email)
db.commit()
except IntegrityError:
db.rollback()
raise OIDCPolicyError("oidc_provisioning_failed")
logger.info("OIDC: provisioned user id=%s username=%s via %s",
user.id, user.username, provider.key)
return user, "created"
+1 -51
View File
@@ -97,44 +97,8 @@ def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> model
return cal return cal
def co_member_group_visible_calendars(db: Session, user: models.User) -> list[models.LocalCalendar]:
"""Calendars that co-members of the user's groups share into the group.
Each user designates ONE of their own calendars via
UserSettings.group_visible_calendar_id. This returns those calendars for
every co-member of any group the user belongs to (excluding the user's own).
The calendar must be owned by the designating member. Deduped (each calendar
appears once even across multiple shared groups).
"""
my_group_ids = (
db.query(models.GroupMember.group_id)
.filter(models.GroupMember.user_id == user.id)
)
co_member_ids = (
db.query(models.GroupMember.user_id)
.filter(
models.GroupMember.group_id.in_(my_group_ids),
models.GroupMember.user_id != user.id,
)
.distinct()
)
return (
db.query(models.LocalCalendar)
.join(
models.UserSettings,
models.UserSettings.group_visible_calendar_id == models.LocalCalendar.id,
)
.filter(
models.UserSettings.user_id.in_(co_member_ids),
# the designating member must own the calendar they share
models.LocalCalendar.user_id == models.UserSettings.user_id,
)
.all()
)
def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]: def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
"""All local calendar ids the user may read: own + shared + group + co-member group-visible.""" """All local calendar ids the user may read: own + shared + group calendars."""
ids: set[int] = set() ids: set[int] = set()
own = ( own = (
@@ -159,18 +123,4 @@ def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
) )
ids.update(r[0] for r in group_cals) ids.update(r[0] for r in group_cals)
# Calendars co-members share into shared groups (group-visible).
ids.update(c.id for c in co_member_group_visible_calendars(db, user))
return list(ids) return list(ids)
def color_prefs_for(db: Session, user_id: int) -> dict[int, str]:
"""Map calendar_id -> the user's personal colour for calendars they don't
own (any sharing path). Empty when the user set no overrides."""
return {
p.calendar_id: p.color
for p in db.query(models.CalendarColorPref).filter(
models.CalendarColorPref.user_id == user_id
)
}
-194
View File
@@ -1,194 +0,0 @@
"""Instance-wide (singleton) settings: an admin-defined default theme plus a
custom logo and favicon. The GET endpoint is public (needed on the login screen,
before auth); all writes require admin. Branding files are stored under
DATA_DIR/branding and served via FileResponse, mirroring the avatar pattern."""
import io
import json
import re
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_admin
from database import DATA_DIR, get_db
router = APIRouter()
BRANDING_DIR = DATA_DIR / "branding"
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
MAX_BRANDING_SIZE = 5 * 1024 * 1024 # 5 MB
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
# Colour keys an admin may set as the instance default theme. Keep in sync with
# the client's DEFAULT_COLORS / DEFAULT_SYNC colour keys (settings-sync.js).
THEME_COLOR_KEYS = {
"primary_color", "accent_color", "today_color", "text_color", "bg_color",
"line_color", "surface_color", "month_divider_color", "month_label_color",
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
"day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color",
}
HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
def _get_or_create(db: Session) -> models.InstanceSettings:
inst = db.query(models.InstanceSettings).filter(models.InstanceSettings.id == 1).first()
if not inst:
inst = models.InstanceSettings(id=1)
db.add(inst)
db.commit()
db.refresh(inst)
return inst
def _mtime(filename: Optional[str]) -> int:
if not filename:
return 0
p = BRANDING_DIR / filename
try:
return int(p.stat().st_mtime)
except OSError:
return 0
def _public_dict(inst: models.InstanceSettings) -> dict:
theme = {}
if inst.default_theme:
try:
theme = json.loads(inst.default_theme) or {}
except (ValueError, TypeError):
theme = {}
has_logo = bool(inst.logo_filename) and (BRANDING_DIR / (inst.logo_filename or "")).exists()
has_favicon = bool(inst.favicon_filename) and (BRANDING_DIR / (inst.favicon_filename or "")).exists()
return {
"default_theme": theme,
"has_logo": has_logo,
"has_favicon": has_favicon,
# Cache-busted URLs so a freshly uploaded asset is fetched immediately.
"logo_url": f"/api/instance/logo?v={_mtime(inst.logo_filename)}" if has_logo else None,
"favicon_url": f"/api/instance/favicon?v={_mtime(inst.favicon_filename)}" if has_favicon else None,
}
# ── Public read ───────────────────────────────────────────
@router.get("/")
def get_instance(db: Session = Depends(get_db)):
return _public_dict(_get_or_create(db))
@router.get("/logo")
def get_logo(db: Session = Depends(get_db)):
inst = _get_or_create(db)
if not inst.logo_filename:
raise HTTPException(404, "No logo")
path = BRANDING_DIR / inst.logo_filename
if not path.exists():
raise HTTPException(404, "No logo")
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
@router.get("/favicon")
def get_favicon(db: Session = Depends(get_db)):
inst = _get_or_create(db)
if not inst.favicon_filename:
raise HTTPException(404, "No favicon")
path = BRANDING_DIR / inst.favicon_filename
if not path.exists():
raise HTTPException(404, "No favicon")
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
# ── Admin writes ──────────────────────────────────────────
class ThemeUpdate(BaseModel):
default_theme: dict # {colorKey: "#RRGGBB"}; empty = reset to built-in
@router.put("/theme")
def set_default_theme(
data: ThemeUpdate,
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
# Keep only known colour keys with valid hex values.
clean = {
k: v.upper()
for k, v in (data.default_theme or {}).items()
if k in THEME_COLOR_KEYS and isinstance(v, str) and HEX_RE.match(v)
}
inst = _get_or_create(db)
inst.default_theme = json.dumps(clean) if clean else None
db.commit()
return {"ok": True, "default_theme": clean}
async def _save_branding(file: UploadFile, kind: str) -> str:
"""Validate + normalise an uploaded image and store it. Returns the filename."""
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(400, "Only JPEG, PNG or WebP allowed")
raw = await file.read()
if len(raw) > MAX_BRANDING_SIZE:
raise HTTPException(400, "File too large (max 5 MB)")
try:
img = Image.open(io.BytesIO(raw)).convert("RGBA")
except Exception:
raise HTTPException(400, "Invalid image")
if kind == "favicon":
img = img.resize((128, 128), Image.LANCZOS)
else: # logo: keep aspect ratio, cap the longest edge at 256px
img.thumbnail((256, 256), Image.LANCZOS)
filename = f"{kind}.png"
img.save(str(BRANDING_DIR / filename), "PNG")
return filename
@router.post("/logo")
async def upload_logo(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
inst = _get_or_create(db)
inst.logo_filename = await _save_branding(file, "logo")
db.commit()
return {"ok": True}
@router.delete("/logo")
def delete_logo(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
inst = _get_or_create(db)
if inst.logo_filename:
p = BRANDING_DIR / inst.logo_filename
if p.exists():
p.unlink()
inst.logo_filename = None
db.commit()
return {"ok": True}
@router.post("/favicon")
async def upload_favicon(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
inst = _get_or_create(db)
inst.favicon_filename = await _save_branding(file, "favicon")
db.commit()
return {"ok": True}
@router.delete("/favicon")
def delete_favicon(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
inst = _get_or_create(db)
if inst.favicon_filename:
p = BRANDING_DIR / inst.favicon_filename
if p.exists():
p.unlink()
inst.favicon_filename = None
db.commit()
return {"ok": True}
+4 -4
View File
@@ -9,7 +9,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import models import models
from auth import create_user_token, 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 get_db from database import get_db
# When "Angemeldet bleiben" is ticked the token lives for half a year. # When "Angemeldet bleiben" is ticked the token lives for half a year.
@@ -61,7 +61,7 @@ def setup(req: SetupRequest, db: Session = Depends(get_db)):
db.add(models.UserSettings(user_id=user.id)) db.add(models.UserSettings(user_id=user.id))
db.commit() db.commit()
db.refresh(user) db.refresh(user)
token = create_user_token(user) token = create_access_token({"sub": user.username})
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
@@ -83,7 +83,7 @@ def login(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="2fa_required", detail="2fa_required",
) )
token = create_user_token(user) token = create_access_token({"sub": user.username})
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
@@ -110,7 +110,7 @@ def login_json(req: LoginRequest, db: Session = Depends(get_db)):
detail="Ungültiger 2FA-Code", detail="Ungültiger 2FA-Code",
) )
expires = REMEMBER_ME_EXPIRY if req.remember_me else None expires = REMEMBER_ME_EXPIRY if req.remember_me else None
token = create_user_token(user, expires_delta=expires) token = create_access_token({"sub": user.username}, expires_delta=expires)
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
-77
View File
@@ -1,77 +0,0 @@
"""Birthday sync device tracking.
The iOS app reports, after each Contacts birthday sync, which device it was and
how many birthdays it manages. The web shows this as a "birthdays come from
these devices" list. Birthday events themselves are ordinary local events
(see local_router); this router only tracks the sync sources.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
router = APIRouter()
class SyncReport(BaseModel):
device_id: str
device_name: str
count: int = 0
@router.post("/sync-report")
def report_sync(
data: SyncReport,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Upsert the (user, device) sync record after a Contacts birthday sync."""
row = (
db.query(models.BirthdaySyncDevice)
.filter(
models.BirthdaySyncDevice.user_id == current_user.id,
models.BirthdaySyncDevice.device_id == data.device_id,
)
.first()
)
now = datetime.now(timezone.utc).isoformat()
name = (data.device_name or "Gerät")[:120]
if row is None:
db.add(models.BirthdaySyncDevice(
user_id=current_user.id, device_id=data.device_id,
device_name=name, last_sync=now, count=data.count,
))
else:
row.device_name = name
row.last_sync = now
row.count = data.count
db.commit()
return {"ok": True}
@router.get("/devices")
def list_devices(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
rows = (
db.query(models.BirthdaySyncDevice)
.filter(models.BirthdaySyncDevice.user_id == current_user.id)
.order_by(models.BirthdaySyncDevice.last_sync.desc())
.all()
)
return [
{
"device_id": r.device_id,
"device_name": r.device_name,
"last_sync": r.last_sync,
"count": r.count,
}
for r in rows
]
+5 -56
View File
@@ -300,7 +300,6 @@ def get_events(
end_dt = end_dt.replace(tzinfo=timezone.utc) end_dt = end_dt.replace(tzinfo=timezone.utc)
all_events = [] all_events = []
sync_errors = []
accounts = ( accounts = (
db.query(models.CalDAVAccount) db.query(models.CalDAVAccount)
.filter( .filter(
@@ -334,12 +333,6 @@ def get_events(
logger.error( logger.error(
"Error fetching calendar %s: %s", calendar.id, exc "Error fetching calendar %s: %s", calendar.id, exc
) )
sync_errors.append({
"source": "caldav",
"name": f"{account.username} – {calendar.name}",
"calendar_id": calendar.id,
"message": "Sync fehlgeschlagen",
})
# ── Local calendar events (own + shared + group calendars) ───────────── # ── Local calendar events (own + shared + group calendars) ─────────────
readable_ids = permissions.readable_local_calendar_ids(db, current_user) readable_ids = permissions.readable_local_calendar_ids(db, current_user)
@@ -352,23 +345,6 @@ def get_events(
.all() .all()
) if readable_ids else [] ) if readable_ids else []
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()} name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
# A personal calendar shared WITH the current user is relabelled under the
# owner's name (so Guido's "Persönlich" reads as "Guido" for his mum) and
# flagged read_only unless the share grants write. Group calendars are
# excluded — they keep their own name and stay writable for members.
shares_by_cal = {
s.calendar_id: s
for s in db.query(models.CalendarShare).filter(
models.CalendarShare.user_id == current_user.id
)
}
group_cal_ids = {
r[0] for r in db.query(models.GroupCalendar.calendar_id).filter(
models.GroupCalendar.calendar_id.in_(readable_ids)
)
} if readable_ids else set()
# Per-user colour overrides for calendars the user doesn't own (any share path).
color_prefs = permissions.color_prefs_for(db, current_user.id)
# Cache each owner's private-event visibility (one lookup per owner, not per event). # Cache each owner's private-event visibility (one lookup per owner, not per event).
vis_cache: dict = {} vis_cache: dict = {}
@@ -378,15 +354,6 @@ def get_events(
return vis_cache[uid] return vis_cache[uid]
for local_cal in local_calendars: for local_cal in local_calendars:
# Decoration for a personal calendar shared with (not owned by) me.
is_shared_personal = (
local_cal.user_id != current_user.id
and local_cal.id not in group_cal_ids
)
share = shares_by_cal.get(local_cal.id) if is_shared_personal else None
shared_owner_name = name_cache.get(local_cal.user_id) if is_shared_personal else None
shared_read_only = is_shared_personal and (share.permission if share else None) != "read_write"
shared_color = color_prefs.get(local_cal.id)
local_events = ( local_events = (
db.query(models.LocalEvent) db.query(models.LocalEvent)
.filter( .filter(
@@ -417,12 +384,6 @@ def get_events(
else: else:
built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)] built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)]
for b in built: for b in built:
if shared_owner_name:
b["calendar_name"] = shared_owner_name
if shared_read_only:
b["read_only"] = True
if shared_color:
b["calendarColor"] = shared_color
b = apply_event_privacy( b = apply_event_privacy(
b, owner_id=owner_id, is_private=is_priv, b, owner_id=owner_id, is_private=is_priv,
requester_id=current_user.id, visibility=visibility, requester_id=current_user.id, visibility=visibility,
@@ -453,18 +414,13 @@ def get_events(
.filter(models.GoogleAccount.user_id == current_user.id) .filter(models.GoogleAccount.user_id == current_user.id)
.all() .all()
) )
google_errors = []
for g_acc in google_accounts: for g_acc in google_accounts:
try: try:
g_events, g_errors = get_google_events(g_acc, start_dt, end_dt, db) all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
all_events.extend(g_events)
sync_errors.extend(g_errors)
except Exception as exc: except Exception as exc:
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc) logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
sync_errors.append({ google_errors.append({"email": g_acc.email})
"source": "google",
"name": g_acc.email,
"message": "Sync fehlgeschlagen",
})
# ── Home Assistant events ───────────────────────────── # ── Home Assistant events ─────────────────────────────
from routers.homeassistant_router import get_ha_events from routers.homeassistant_router import get_ha_events
@@ -475,18 +431,11 @@ def get_events(
) )
for ha_acc in ha_accounts: for ha_acc in ha_accounts:
try: try:
ha_events, ha_errors = get_ha_events(ha_acc, start_dt, end_dt, db) all_events.extend(get_ha_events(ha_acc, start_dt, end_dt, db))
all_events.extend(ha_events)
sync_errors.extend(ha_errors)
except Exception as exc: except Exception as exc:
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc) logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
sync_errors.append({
"source": "homeassistant",
"name": ha_acc.name,
"message": "Sync fehlgeschlagen",
})
return {"events": all_events, "errors": sync_errors} return {"events": all_events, "errors": google_errors}
@router.post("/events") @router.post("/events")
-491
View File
@@ -1,491 +0,0 @@
"""Minimal two-way CalDAV server for published local calendars.
Two ways to reach a published local calendar:
1. Secret token URL (no login) — ``/dav/{token}/``.
2. Username + password (HTTP Basic Auth) with principal discovery —
``/caldav/`` advertises the user's calendar-home-set and lists every
published calendar as ``/caldav/{id}/``. This is what account-based clients
(Apple Calendar, DAVx5, Thunderbird) use when you enter server + credentials.
Supported methods: OPTIONS, PROPFIND, REPORT (calendar-query / calendar-multiget),
GET, PUT, DELETE. Change detection is ctag-based (CS:getctag on the collection +
getetag per event), avoiding deletion tombstones.
Reuses ``ical_io.build_ics`` / ``parse_ics``. Note: VALARM/reminders are not
round-tripped (parse_ics ignores them).
"""
from __future__ import annotations
import base64
import uuid
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from urllib.parse import quote, unquote
from xml.sax.saxutils import escape as xml_escape
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse, Response
from sqlalchemy import func
from sqlalchemy.orm import Session
import dav_util
import ical_io
import models
from auth import verify_password
from database import get_db
router = APIRouter()
# XML namespaces used across WebDAV / CalDAV.
NS_DAV = "DAV:"
NS_CAL = "urn:ietf:params:xml:ns:caldav"
NS_CS = "http://calendarserver.org/ns/"
NS_ICAL = "http://apple.com/ns/ical/"
_NS_DECL = (
'xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:caldav" '
'xmlns:CS="http://calendarserver.org/ns/" '
'xmlns:ICAL="http://apple.com/ns/ical/"'
)
_ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT"
_MULTISTATUS_CT = "application/xml; charset=utf-8"
_LOGIN_HREF = "/caldav/"
# ── Helpers ───────────────────────────────────────────────
def _resolve(token: str, db: Session) -> models.LocalCalendar | None:
if not token:
return None
return (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.dav_token == token,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.first()
)
def _basic_auth_user(request: Request, db: Session) -> models.User | None:
"""Validate an HTTP Basic Authorization header against a Calendarr account.
Accepts an app-specific password (always) or the account password (only when
MFA is off — otherwise the account password would bypass 2FA, which CalDAV
clients can't satisfy).
"""
hdr = request.headers.get("Authorization", "")
if not hdr.lower().startswith("basic "):
return None
try:
raw = base64.b64decode(hdr.split(" ", 1)[1]).decode("utf-8")
except Exception:
return None
username, sep, password = raw.partition(":")
if not sep:
return None
# Login names are stored lowercase; match case-insensitively like the web login.
user = (
db.query(models.User)
.filter(func.lower(models.User.username) == username.lower())
.first()
)
if not user:
return None
# 1) App-specific passwords — always allowed, MFA-safe.
for ap in db.query(models.AppPassword).filter(models.AppPassword.user_id == user.id).all():
try:
if verify_password(password, ap.password_hash):
ap.last_used_at = datetime.now(timezone.utc).isoformat()
db.commit()
return user
except Exception:
continue
# 2) Account password — only when 2FA is disabled.
if not user.totp_enabled:
try:
if verify_password(password, user.password_hash):
return user
except Exception:
return None
return None
def _unauthorized() -> Response:
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Calendarr CalDAV"'})
def _published_calendars(user: models.User, db: Session) -> list[models.LocalCalendar]:
return (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == user.id,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.all()
)
def _events(cal: models.LocalCalendar, db: Session) -> list[models.LocalEvent]:
return (
db.query(models.LocalEvent)
.filter(models.LocalEvent.calendar_id == cal.id)
.all()
)
def _etag(ev: models.LocalEvent) -> str:
return ev.etag or "0"
def _resource_name(ev: models.LocalEvent) -> str:
return f"{quote(ev.uid, safe='')}.ics"
def _event_href(base: str, ev: models.LocalEvent) -> str:
return f"{base}{_resource_name(ev)}"
def _name_cache(cal: models.LocalCalendar, db: Session) -> dict:
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
if owner:
return {owner.id: (owner.display_name or owner.username)}
return {}
def _build_ics(cal: models.LocalCalendar, evs: list[models.LocalEvent], db: Session) -> str:
return ical_io.build_ics(cal, evs, name_cache=_name_cache(cal, db))
# ── XML builders ──────────────────────────────────────────
def _collection_propstat(cal: models.LocalCalendar, base: str, *,
principal_href: str | None = None,
home_href: str | None = None) -> str:
principal_href = principal_href or base
home_href = home_href or base
return f""" <D:response>
<D:href>{base}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<D:displayname>{xml_escape(cal.name or "")}</D:displayname>
<CS:getctag>{xml_escape(cal.dav_ctag or "0")}</CS:getctag>
<D:supported-report-set>
<D:supported-report><D:report><C:calendar-query/></D:report></D:supported-report>
<D:supported-report><D:report><C:calendar-multiget/></D:report></D:supported-report>
</D:supported-report-set>
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
<ICAL:calendar-color>{xml_escape(cal.color or "#34a853")}</ICAL:calendar-color>
<D:current-user-principal><D:href>{principal_href}</D:href></D:current-user-principal>
<D:principal-URL><D:href>{principal_href}</D:href></D:principal-URL>
<C:calendar-home-set><D:href>{home_href}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _principal_propstat(user: models.User) -> str:
href = _LOGIN_HREF
name = user.display_name or user.username
return f""" <D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><D:principal/></D:resourcetype>
<D:displayname>{xml_escape(name)}</D:displayname>
<D:current-user-principal><D:href>{href}</D:href></D:current-user-principal>
<D:principal-URL><D:href>{href}</D:href></D:principal-URL>
<C:calendar-home-set><D:href>{href}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _event_propstat(base: str, ev: models.LocalEvent, *, with_data: bool = False,
ics: str | None = None) -> str:
href = _event_href(base, ev)
data = ""
if with_data and ics is not None:
data = f"\n <C:calendar-data>{xml_escape(ics)}</C:calendar-data>"
return f""" <D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getetag>"{xml_escape(_etag(ev))}"</D:getetag>
<D:getcontenttype>text/calendar; charset=utf-8; component=VEVENT</D:getcontenttype>{data}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _multistatus(body: str) -> Response:
xml = f'<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus {_NS_DECL}>\n{body}\n</D:multistatus>'
return Response(content=xml, status_code=207, media_type=_MULTISTATUS_CT)
# ── Method handlers (shared by token and Basic-Auth paths) ──
def _handle_options() -> Response:
return Response(status_code=200, headers={
"DAV": "1, 2, 3, calendar-access",
"Allow": _ALLOW,
})
def _find_event(cal: models.LocalCalendar, resource: str, db: Session) -> models.LocalEvent | None:
name = resource.rsplit("/", 1)[-1]
if name.endswith(".ics"):
name = name[:-4]
uid = unquote(name)
return (
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == cal.id,
models.LocalEvent.uid == uid,
)
.first()
)
def _handle_propfind(cal: models.LocalCalendar, base: str, resource: str, depth: str,
db: Session, *, principal_href: str | None = None,
home_href: str | None = None) -> Response:
# PROPFIND on a single event resource.
if resource:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
return _multistatus(_event_propstat(base, ev))
# Collection: always include collection props; Depth:1 adds each event.
parts = [_collection_propstat(cal, base, principal_href=principal_href, home_href=home_href)]
if depth != "0":
for ev in _events(cal, db):
parts.append(_event_propstat(base, ev))
return _multistatus("\n".join(parts))
def _handle_report(cal: models.LocalCalendar, base: str, body: bytes, db: Session) -> Response:
report_type = None
hrefs: list[str] = []
if body:
try:
root = ET.fromstring(body)
report_type = root.tag.split("}")[-1] # calendar-query | calendar-multiget
hrefs = [el.text for el in root.iter(f"{{{NS_DAV}}}href") if el.text]
except ET.ParseError:
pass
if report_type == "calendar-multiget" and hrefs:
wanted = {unquote(h.rstrip("/").rsplit("/", 1)[-1]) for h in hrefs}
evs = [ev for ev in _events(cal, db) if f"{ev.uid}.ics" in wanted]
else:
# calendar-query (or unknown) → return the whole calendar.
evs = _events(cal, db)
parts = []
for ev in evs:
ics = _build_ics(cal, [ev], db)
parts.append(_event_propstat(base, ev, with_data=True, ics=ics))
return _multistatus("\n".join(parts) if parts else "")
def _handle_get(cal: models.LocalCalendar, resource: str, db: Session,
*, head: bool = False) -> Response:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
ics = _build_ics(cal, [ev], db)
headers = {"ETag": f'"{_etag(ev)}"'}
return Response(
content=b"" if head else ics,
media_type="text/calendar; charset=utf-8",
headers=headers,
)
def _handle_put(cal: models.LocalCalendar, resource: str, body: bytes, db: Session) -> Response:
try:
parsed = ical_io.parse_ics(body)
except ValueError:
return Response(status_code=400)
items = parsed.get("events") or []
if not items:
return Response(status_code=400)
item = items[0]
# Key by the VEVENT UID; fall back to the resource name.
uid = item.get("uid")
if not uid:
name = resource.rsplit("/", 1)[-1]
uid = unquote(name[:-4] if name.endswith(".ics") else name) or str(uuid.uuid4())
# Scope to THIS calendar — never touch another calendar's/user's event that
# happens to share the UID (local_events.uid is globally unique).
ev = (
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == cal.id,
models.LocalEvent.uid == uid,
)
.first()
)
created = ev is None
if created:
# If the UID already exists elsewhere, the global UNIQUE constraint would
# 500 on commit — reject cleanly with 409 instead.
if db.query(models.LocalEvent.id).filter(models.LocalEvent.uid == uid).first():
return Response(status_code=409)
ev = models.LocalEvent(calendar_id=cal.id, uid=uid, creator_id=cal.user_id)
db.add(ev)
ev.title = item.get("title") or "(ohne Titel)"
ev.start = item["start"]
ev.end = item["end"]
ev.all_day = item.get("all_day", False)
ev.location = item.get("location")
ev.description = item.get("description")
ev.rrule = item.get("rrule")
ev.exdate = item.get("exdate")
dav_util.bump_dav(cal, ev)
db.commit()
db.refresh(ev)
return Response(status_code=201 if created else 204, headers={"ETag": f'"{_etag(ev)}"'})
def _handle_delete(cal: models.LocalCalendar, resource: str, db: Session) -> Response:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
dav_util.bump_dav(cal)
db.delete(ev)
db.commit()
return Response(status_code=204)
async def _dispatch_collection(request: Request, cal: models.LocalCalendar, base: str,
resource: str, db: Session, *,
principal_href: str | None = None,
home_href: str | None = None) -> Response:
"""Serve a single calendar collection; auth/ownership already checked."""
method = request.method.upper()
if method == "PROPFIND":
depth = request.headers.get("Depth", "0")
return _handle_propfind(cal, base, resource, depth, db,
principal_href=principal_href, home_href=home_href)
if method == "REPORT":
return _handle_report(cal, base, await request.body(), db)
if method in ("GET", "HEAD"):
if not resource:
ics = _build_ics(cal, _events(cal, db), db)
return Response(content=ics, media_type="text/calendar; charset=utf-8")
return _handle_get(cal, resource, db, head=(method == "HEAD"))
if method == "PUT":
return _handle_put(cal, resource, await request.body(), db)
if method == "DELETE":
if not resource:
return Response(status_code=403) # don't delete the collection itself
return _handle_delete(cal, resource, db)
return Response(status_code=405, headers={"Allow": _ALLOW})
# ── Token path (no login): /dav/{token}/… ─────────────────
async def _dispatch_token(request: Request, token: str, resource: str, db: Session) -> Response:
if request.method.upper() == "OPTIONS":
return _handle_options()
cal = _resolve(token, db)
if not cal:
return Response(status_code=404)
base = f"/dav/{token}/"
return await _dispatch_collection(request, cal, base, resource, db)
_METHODS = ["OPTIONS", "GET", "HEAD", "PUT", "DELETE", "PROPFIND", "REPORT"]
@router.api_route("/dav/{token}", methods=_METHODS, include_in_schema=False)
async def dav_collection(token: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch_token(request, token, "", db)
@router.api_route("/dav/{token}/{resource:path}", methods=_METHODS, include_in_schema=False)
async def dav_resource(token: str, resource: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch_token(request, token, resource, db)
# ── Basic-Auth path (username/password): /caldav/… ────────
async def _dispatch_home(request: Request, db: Session) -> Response:
"""Principal + calendar-home-set: lists the user's published calendars."""
if request.method.upper() == "OPTIONS":
return _handle_options()
user = _basic_auth_user(request, db)
if not user:
return _unauthorized()
if request.method.upper() != "PROPFIND":
return Response(status_code=405, headers={"Allow": _ALLOW})
depth = request.headers.get("Depth", "0")
parts = [_principal_propstat(user)]
if depth != "0":
for cal in _published_calendars(user, db):
parts.append(_collection_propstat(
cal, f"/caldav/{cal.id}/",
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF))
return _multistatus("\n".join(parts))
async def _dispatch_auth_calendar(request: Request, cal_id: int, resource: str, db: Session) -> Response:
if request.method.upper() == "OPTIONS":
return _handle_options()
user = _basic_auth_user(request, db)
if not user:
return _unauthorized()
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == cal_id,
models.LocalCalendar.user_id == user.id,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.first()
)
if not cal:
return Response(status_code=404)
base = f"/caldav/{cal.id}/"
return await _dispatch_collection(request, cal, base, resource, db,
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF)
@router.api_route("/.well-known/caldav", methods=["OPTIONS", "GET", "PROPFIND"], include_in_schema=False)
async def wellknown_caldav(request: Request):
if request.method.upper() == "OPTIONS":
return _handle_options()
# Point discovery at the principal/home collection.
return RedirectResponse(url=_LOGIN_HREF, status_code=301)
@router.api_route("/caldav", methods=_METHODS, include_in_schema=False)
@router.api_route("/caldav/", methods=_METHODS, include_in_schema=False)
async def caldav_home(request: Request, db: Session = Depends(get_db)):
return await _dispatch_home(request, db)
@router.api_route("/caldav/{cal_id:int}", methods=_METHODS, include_in_schema=False)
async def caldav_calendar(cal_id: int, request: Request, db: Session = Depends(get_db)):
return await _dispatch_auth_calendar(request, cal_id, "", db)
@router.api_route("/caldav/{cal_id:int}/{resource:path}", methods=_METHODS, include_in_schema=False)
async def caldav_calendar_resource(cal_id: int, resource: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch_auth_calendar(request, cal_id, resource, db)
+5 -31
View File
@@ -382,35 +382,15 @@ def update_calendar(
# ── Events ─────────────────────────────────────────────── # ── Events ───────────────────────────────────────────────
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple: def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
"""Fetch events from all enabled Google calendars for an account. """Fetch events from all enabled Google calendars for an account."""
Returns (events, errors) — errors is a list of
{"source": "google", "name": ..., "message": ...} dicts for any
calendar that failed to sync. Never includes raw exception text.
"""
all_events = []
errors = []
try: try:
token = _refresh_access_token(account, db) token = _refresh_access_token(account, db)
except Exception as exc: except Exception as exc:
# A token failure aborts the whole account, but attribute it to each
# enabled calendar so clients can pin the error to a specific calendar
# (and preserve that calendar's cached events instead of wiping it).
logger.error("Token refresh failed for Google account %s: %s", account.email, exc) logger.error("Token refresh failed for Google account %s: %s", account.email, exc)
for gcal in account.calendars: raise
if not gcal.enabled or gcal.sidebar_hidden:
continue
if _is_system_calendar(gcal.cal_id):
continue
errors.append({
"source": "google",
"name": f"{account.email} – {gcal.name}",
"calendar_id": gcal.id,
"message": "Sync fehlgeschlagen",
})
return all_events, errors
all_events = []
for gcal in account.calendars: for gcal in account.calendars:
if not gcal.enabled or gcal.sidebar_hidden: if not gcal.enabled or gcal.sidebar_hidden:
continue continue
@@ -429,14 +409,8 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4")) all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4"))
except Exception as exc: except Exception as exc:
logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc) logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc)
errors.append({
"source": "google",
"name": f"{account.email} – {gcal.name}",
"calendar_id": gcal.id,
"message": "Sync fehlgeschlagen",
})
return all_events, errors return all_events
class GoogleEventCreate(BaseModel): class GoogleEventCreate(BaseModel):
+17 -29
View File
@@ -176,22 +176,11 @@ def _group_detail(db: Session, group: models.Group, current_user: models.User) -
member_dicts = [] member_dicts = []
for i, m in enumerate(members): for i, m in enumerate(members):
u = db.query(models.User).filter(models.User.id == m.user_id).first() u = db.query(models.User).filter(models.User.id == m.user_id).first()
# Whether this member actually shares a calendar into the group (owns a
# calendar designated as their group_visible). Lets clients hide phantom
# empty rows for members who share nothing.
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == m.user_id).first()
shares_calendar = False
if s and s.group_visible_calendar_id is not None:
shares_calendar = db.query(models.LocalCalendar.id).filter(
models.LocalCalendar.id == s.group_visible_calendar_id,
models.LocalCalendar.user_id == m.user_id,
).first() is not None
member_dicts.append({ member_dicts.append({
"id": m.user_id, "id": m.user_id,
"display_name": (u.display_name or u.username) if u else None, "display_name": (u.display_name or u.username) if u else None,
"role": m.role, "role": m.role,
"color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)], "color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)],
"shares_calendar": shares_calendar,
}) })
gcal_id = _group_calendar_id(db, group.id) gcal_id = _group_calendar_id(db, group.id)
return { return {
@@ -328,12 +317,17 @@ def _first_name(name: Optional[str]) -> str:
def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict], def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict],
owner: Optional[dict], me_id: int) -> str: owner: Optional[dict], me_id: int) -> str:
"""Server-side display title for the combined view. The former owner/creator """Server-side display title for the combined view so every client (web,
first-name prefix ("Guido: …") was dropped: each member already has a iOS, Android) renders identically: another member's / creator's first name
distinct colour, so the prefix was redundant noise. We still return a is prefixed. No icon glyph is embedded — group icons are semantic keys the
non-empty `display_title` (== raw title) so the clients' legacy fallback — clients render as native vector icons, and group-calendar events are
which rebuilds a prefix when `display_title` is empty — never kicks in. distinguished by their (group) colour. The raw `title` stays for editing."""
`display_color` continues to carry the per-person colour.""" 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 return title
@@ -380,9 +374,6 @@ def combined_events(
def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool): def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool):
owner_user = name_cache.get(owner_id) owner_user = name_cache.get(owner_id)
owner = {"id": owner_id, "display_name": owner_user} owner = {"id": owner_id, "display_name": owner_user}
# Editable by the requester iff it's the shared group calendar (all members
# may write) or the requester's own calendar; everyone else's is read-only.
read_only = not (is_group or owner_id == current_user.id)
events = ( events = (
db.query(models.LocalEvent) db.query(models.LocalEvent)
.filter( .filter(
@@ -408,9 +399,9 @@ def combined_events(
creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"} creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
if ev.rrule: if ev.rrule:
built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only) built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group)
else: else:
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)] built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group)]
for b in built: for b in built:
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy": if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
@@ -420,13 +411,10 @@ def combined_events(
b["display_color"] = group_cal_color if is_group else member_color.get(owner_id) b["display_color"] = group_cal_color if is_group else member_color.get(owner_id)
# Decorated title (group icon / owner name) computed server-side # Decorated title (group icon / owner name) computed server-side
# so all clients render identically; raw `title` kept for editing. # so all clients render identically; raw `title` kept for editing.
# A birthday event already carries an age display_title from b["display_title"] = _decorate_title(
# build_local_event_dict — keep it rather than clobber the age. b.get("title", ""), is_group=is_group, creator=b.get("creator"),
if not b.get("is_birthday"): owner=owner, me_id=current_user.id,
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) all_events.append(b)
# Each member shares exactly one calendar into their groups, chosen in their # Each member shares exactly one calendar into their groups, chosen in their
+3 -28
View File
@@ -313,32 +313,13 @@ def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) ->
} }
def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple: def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
"""Fetch events from all enabled HA calendars for an account.
Returns (events, errors) — errors is a list of
{"source": "homeassistant", "name": ..., "calendar_id": ..., "message": ...}
dicts for any calendar that failed to sync. Never includes raw exception text.
"""
all_events = [] all_events = []
errors = []
try: try:
token = _get_valid_token(account, db) token = _get_valid_token(account, db)
except Exception as exc: except Exception as exc:
# A token failure aborts the whole account, but attribute it to each
# enabled calendar so clients can pin the error to a specific calendar
# (and preserve that calendar's cached events instead of wiping it).
logger.error("HA token error for %s: %s", account.name, exc) logger.error("HA token error for %s: %s", account.name, exc)
for cal in account.calendars: raise
if not cal.enabled or cal.sidebar_hidden:
continue
errors.append({
"source": "homeassistant",
"name": f"{account.name} – {cal.name}",
"calendar_id": cal.id,
"message": "Sync fehlgeschlagen",
})
return all_events, errors
for cal in account.calendars: for cal in account.calendars:
if not cal.enabled or cal.sidebar_hidden: if not cal.enabled or cal.sidebar_hidden:
continue continue
@@ -349,13 +330,7 @@ def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_
all_events.append(_parse_ha_event(ev, cal.id, cal.name, color)) all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
except Exception as exc: except Exception as exc:
logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc) logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc)
errors.append({ return all_events
"source": "homeassistant",
"name": f"{account.name} – {cal.name}",
"calendar_id": cal.id,
"message": "Sync fehlgeschlagen",
})
return all_events, errors
# ── Serialization ───────────────────────────────────────── # ── Serialization ─────────────────────────────────────────
+5 -66
View File
@@ -1,9 +1,6 @@
import ipaddress
import logging import logging
import socket
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from urllib.parse import urljoin, urlparse
import requests as http_requests import requests as http_requests
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -31,7 +28,6 @@ class SubscriptionUpdate(BaseModel):
url: Optional[str] = None url: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
enabled: Optional[bool] = None enabled: Optional[bool] = None
sidebar_hidden: Optional[bool] = None
refresh_minutes: Optional[int] = None refresh_minutes: Optional[int] = None
reminders_enabled: Optional[bool] = None reminders_enabled: Optional[bool] = None
@@ -43,74 +39,19 @@ def _sub_dict(sub: models.ICalSubscription) -> dict:
"url": sub.url, "url": sub.url,
"color": sub.color, "color": sub.color,
"enabled": sub.enabled, "enabled": sub.enabled,
"sidebar_hidden": bool(sub.sidebar_hidden),
"reminders_enabled": bool(sub.reminders_enabled), "reminders_enabled": bool(sub.reminders_enabled),
"refresh_minutes": sub.refresh_minutes, "refresh_minutes": sub.refresh_minutes,
"last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None, "last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None,
} }
_MAX_ICS_BYTES = 5 * 1024 * 1024
_MAX_REDIRECTS = 5
def _host_is_public(host: str) -> bool:
"""False if the host resolves to any private/loopback/link-local address."""
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror:
return False
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
return False
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
return False
return True
def _validate_public_url(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("Nur http(s)-URLs sind erlaubt.")
if not parsed.hostname:
raise ValueError("Ungültige URL.")
if not _host_is_public(parsed.hostname):
raise ValueError("Interne/private Adressen sind nicht erlaubt.")
def _fetch_ics(url: str) -> str: def _fetch_ics(url: str) -> str:
"""Download .ics content, blocking SSRF to internal/private hosts. """Download .ics content from a URL."""
Redirects are followed manually so every hop is re-validated (a public URL
can otherwise 30x-redirect into the internal network). Response size is
capped. (Residual risk: DNS rebinding between check and connect.)
"""
if url.startswith("webcal://"):
url = "https://" + url[len("webcal://"):]
try: try:
for _ in range(_MAX_REDIRECTS + 1): resp = http_requests.get(url, timeout=30, allow_redirects=True)
_validate_public_url(url) resp.raise_for_status()
resp = http_requests.get(url, timeout=30, allow_redirects=False, stream=True) resp.encoding = 'utf-8'
if resp.is_redirect and resp.headers.get("location"): return resp.text
url = urljoin(url, resp.headers["location"])
resp.close()
continue
resp.raise_for_status()
resp.encoding = "utf-8"
chunks, total = [], 0
for chunk in resp.iter_content(8192, decode_unicode=True):
if not chunk:
continue
chunks.append(chunk)
total += len(chunk)
if total > _MAX_ICS_BYTES:
resp.close()
raise ValueError("Datei zu groß (max. 5 MB).")
return "".join(chunks)
raise ValueError("Zu viele Weiterleitungen.")
except http_requests.RequestException as e: except http_requests.RequestException as e:
raise ValueError(f"Fehler beim Abrufen der URL: {e}") raise ValueError(f"Fehler beim Abrufen der URL: {e}")
@@ -275,8 +216,6 @@ def update_subscription(
sub.color = data.color sub.color = data.color
if data.enabled is not None: if data.enabled is not None:
sub.enabled = data.enabled sub.enabled = data.enabled
if data.sidebar_hidden is not None:
sub.sidebar_hidden = data.sidebar_hidden
if data.refresh_minutes is not None: if data.refresh_minutes is not None:
sub.refresh_minutes = data.refresh_minutes sub.refresh_minutes = data.refresh_minutes
if data.reminders_enabled is not None: if data.reminders_enabled is not None:
+23 -271
View File
@@ -2,12 +2,11 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import dav_util
import ical_io import ical_io
import models import models
import permissions import permissions
@@ -25,19 +24,13 @@ def _now_iso() -> str:
class CalendarCreate(BaseModel): class CalendarCreate(BaseModel):
name: str name: str
color: str = "#34a853" color: str = "#34a853"
is_birthday: bool = False
birthday_notify_days_before: Optional[int] = None
class CalendarUpdate(BaseModel): class CalendarUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
enabled: Optional[bool] = None enabled: Optional[bool] = None
sidebar_hidden: Optional[bool] = None
reminders_enabled: Optional[bool] = None reminders_enabled: Optional[bool] = None
caldav_published: Optional[bool] = None
is_birthday: Optional[bool] = None
birthday_notify_days_before: Optional[int] = None
class EventCreate(BaseModel): class EventCreate(BaseModel):
@@ -52,8 +45,6 @@ class EventCreate(BaseModel):
rrule: Optional[str] = None rrule: Optional[str] = None
private: bool = False private: bool = False
reminders: Optional[List[int]] = None # minutes before start (0 = at start) reminders: Optional[List[int]] = None # minutes before start (0 = at start)
external_uid: Optional[str] = None # stable id for imported entries (Contacts dedup)
birth_year: Optional[int] = None # birthday events; NULL = year unknown
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
@@ -68,8 +59,6 @@ class EventUpdate(BaseModel):
exdate: Optional[str] = None exdate: Optional[str] = None
private: Optional[bool] = None private: Optional[bool] = None
reminders: Optional[List[int]] = None reminders: Optional[List[int]] = None
external_uid: Optional[str] = None
birth_year: Optional[int] = None
class ShareCreate(BaseModel): class ShareCreate(BaseModel):
@@ -78,29 +67,16 @@ class ShareCreate(BaseModel):
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
shared_by: Optional[str] = None, permission: Optional[str] = None, shared_by: Optional[str] = None, permission: Optional[str] = None) -> dict:
color_override: Optional[str] = None,
request: Optional[Request] = None) -> dict:
d = { d = {
"id": cal.id, "id": cal.id,
# A shared calendar is labelled by the person/group it comes from — the "name": cal.name,
# owner's real calendar name must never reach recipients. "color": cal.color,
"name": shared_by if (not owned and shared_by is not None) else cal.name,
# A recipient's own colour for a shared calendar wins over the owner's.
"color": color_override or cal.color,
"enabled": cal.enabled, "enabled": cal.enabled,
"sidebar_hidden": bool(cal.sidebar_hidden),
"reminders_enabled": bool(cal.reminders_enabled), "reminders_enabled": bool(cal.reminders_enabled),
"caldav_published": bool(cal.caldav_published),
"is_birthday": bool(cal.is_birthday),
"birthday_notify_days_before": cal.birthday_notify_days_before,
"type": "local", "type": "local",
"owned": owned, "owned": owned,
} }
# Only the owner may publish; expose the subscribe URLs only when active.
if owned and cal.caldav_published and cal.dav_token:
d["caldav_url"] = dav_util.caldav_url(request, cal.dav_token) if request else None
d["caldav_login_url"] = dav_util.caldav_login_url(request) if request else None
if shared_by is not None: if shared_by is not None:
d["shared_by"] = shared_by d["shared_by"] = shared_by
if permission is not None: if permission is not None:
@@ -116,7 +92,6 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -
@router.get("/calendars") @router.get("/calendars")
def list_calendars( def list_calendars(
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
@@ -134,9 +109,6 @@ def list_calendars(
) )
} }
# Per-user colour overrides for calendars the user doesn't own (any sharing path).
color_prefs = permissions.color_prefs_for(db, current_user.id)
# Own calendars # Own calendars
own = ( own = (
db.query(models.LocalCalendar) db.query(models.LocalCalendar)
@@ -145,7 +117,7 @@ def list_calendars(
) )
result = [] result = []
for c in own: for c in own:
d = _cal_dict(c, owned=True, request=request) d = _cal_dict(c, owned=True)
if c.id in group_cal_map: if c.id in group_cal_map:
d["group"] = True d["group"] = True
d["shared_by"] = group_cal_map[c.id] # group name, for labelling d["shared_by"] = group_cal_map[c.id] # group name, for labelling
@@ -169,7 +141,6 @@ def list_calendars(
cal, owned=False, cal, owned=False,
shared_by=(owner.display_name or owner.username) if owner else None, shared_by=(owner.display_name or owner.username) if owner else None,
permission=share.permission, permission=share.permission,
color_override=color_prefs.get(cal.id),
) )
if cal.id in group_cal_map: if cal.id in group_cal_map:
d["group"] = True d["group"] = True
@@ -184,28 +155,9 @@ def list_calendars(
if not cal: if not cal:
continue continue
seen_ids.add(cal_id) seen_ids.add(cal_id)
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write", d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write")
color_override=color_prefs.get(cal.id))
d["group"] = True d["group"] = True
result.append(d) result.append(d)
# Calendars co-members share into shared groups (group_visible_calendar_id).
# Read-only, shown under the owner's name. Deduped against everything above
# so a calendar already shared directly / as a group calendar isn't doubled.
for cal in permissions.co_member_group_visible_calendars(db, current_user):
if 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="read",
color_override=color_prefs.get(cal.id),
request=request,
)
d["group_shared"] = True
result.append(d)
return result return result
@@ -215,27 +167,10 @@ def create_calendar(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
# Only ONE birthday calendar per account — hard server guarantee. If the
# user already has one, "create birthday calendar" is idempotent: return the
# existing one instead of creating a second.
if data.is_birthday:
existing = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == current_user.id,
models.LocalCalendar.is_birthday == True,
)
.first()
)
if existing is not None:
return _cal_dict(existing)
cal = models.LocalCalendar( cal = models.LocalCalendar(
user_id=current_user.id, user_id=current_user.id,
name=data.name, name=data.name,
color=data.color, color=data.color,
is_birthday=data.is_birthday,
birthday_notify_days_before=data.birthday_notify_days_before,
) )
db.add(cal) db.add(cal)
db.commit() db.commit()
@@ -247,7 +182,6 @@ def create_calendar(
def update_calendar( def update_calendar(
calendar_id: int, calendar_id: int,
data: CalendarUpdate, data: CalendarUpdate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
@@ -267,112 +201,10 @@ def update_calendar(
cal.color = data.color cal.color = data.color
if data.enabled is not None: if data.enabled is not None:
cal.enabled = data.enabled cal.enabled = data.enabled
if data.sidebar_hidden is not None:
cal.sidebar_hidden = data.sidebar_hidden
if data.reminders_enabled is not None: if data.reminders_enabled is not None:
cal.reminders_enabled = data.reminders_enabled cal.reminders_enabled = data.reminders_enabled
if data.is_birthday is not None:
# Never let a second calendar be marked as the birthday calendar.
if data.is_birthday and not cal.is_birthday:
other = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == current_user.id,
models.LocalCalendar.is_birthday == True,
models.LocalCalendar.id != cal.id,
)
.first()
)
if other is not None:
raise HTTPException(422, "A birthday calendar already exists")
cal.is_birthday = data.is_birthday
if data.birthday_notify_days_before is not None:
# -1 is the client's sentinel for "clear the reminder" (JSON has no way to
# send SQL NULL through an Optional that also means "unchanged").
cal.birthday_notify_days_before = (
None if data.birthday_notify_days_before < 0 else data.birthday_notify_days_before
)
if data.caldav_published is not None:
cal.caldav_published = data.caldav_published
if data.caldav_published:
# First publish: mint a token + initial ctag so clients can sync.
if not cal.dav_token:
cal.dav_token = dav_util.new_token()
if not cal.dav_ctag:
cal.dav_ctag = dav_util.new_tag()
else:
# Unpublishing revokes access: drop the token so the URL 404s.
cal.dav_token = None
db.commit() db.commit()
db.refresh(cal) return {"ok": True}
return _cal_dict(cal, owned=True, request=request)
class CalendarColorUpdate(BaseModel):
color: str
@router.put("/calendars/{calendar_id}/color")
def set_calendar_color(
calendar_id: int,
data: CalendarColorUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Set a calendar's colour. The owner changes the calendar's colour for
everyone; anyone else who can see the calendar (direct share, group calendar,
or a co-member's group-visible calendar) sets only their OWN per-user colour.
Recipients may recolour but never rename a shared calendar."""
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first()
if cal is None:
raise HTTPException(404, "Calendar not found")
if cal.user_id == current_user.id:
cal.color = data.color
else:
if calendar_id not in permissions.readable_local_calendar_ids(db, current_user):
raise HTTPException(403, "You cannot access this calendar")
pref = (
db.query(models.CalendarColorPref)
.filter(
models.CalendarColorPref.calendar_id == calendar_id,
models.CalendarColorPref.user_id == current_user.id,
)
.first()
)
if pref:
pref.color = data.color
else:
db.add(models.CalendarColorPref(
calendar_id=calendar_id, user_id=current_user.id, color=data.color))
db.commit()
return {"ok": True, "color": data.color}
@router.post("/calendars/{calendar_id}/dav-token/rotate")
def rotate_dav_token(
calendar_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Issue a fresh CalDAV token — the old subscribe URL stops working."""
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
if not cal.caldav_published:
raise HTTPException(422, "Calendar is not published")
cal.dav_token = dav_util.new_token()
cal.dav_ctag = dav_util.new_tag()
db.commit()
db.refresh(cal)
return _cal_dict(cal, owned=True, request=request)
@router.delete("/calendars/{calendar_id}") @router.delete("/calendars/{calendar_id}")
@@ -409,56 +241,22 @@ def create_event(
db, current_user, data.calendar_id, require_write=True db, current_user, data.calendar_id, require_write=True
) )
# Idempotent on external_uid: repeated syncs of the same contact (stable ev = models.LocalEvent(
# "contact:<deviceId>:<contactId>") must NEVER create duplicates. If a row calendar_id=cal.id,
# with this external_uid already exists in this calendar, update it in place uid=str(uuid.uuid4()),
# instead of inserting a new one. Enforced server-side, so the client can title=data.title,
# never duplicate — even without the reconcile read. start=data.start,
existing = None end=data.end,
if data.external_uid: all_day=data.allDay,
existing = ( location=data.location,
db.query(models.LocalEvent) description=data.description,
.filter( color=data.color,
models.LocalEvent.calendar_id == cal.id, rrule=data.rrule,
models.LocalEvent.external_uid == data.external_uid, is_private=data.private,
) reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None),
.first() creator_id=current_user.id, # server-side, never from the client
) )
db.add(ev)
reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
if existing is not None:
ev = existing
ev.title = data.title
ev.start = data.start
ev.end = data.end
ev.all_day = data.allDay
ev.location = data.location
ev.description = data.description
ev.color = data.color
ev.rrule = data.rrule
ev.is_private = data.private
ev.reminders = reminders
ev.birth_year = data.birth_year
else:
ev = models.LocalEvent(
calendar_id=cal.id,
uid=str(uuid.uuid4()),
title=data.title,
start=data.start,
end=data.end,
all_day=data.allDay,
location=data.location,
description=data.description,
color=data.color,
rrule=data.rrule,
is_private=data.private,
reminders=reminders,
external_uid=data.external_uid,
birth_year=data.birth_year,
creator_id=current_user.id, # server-side, never from the client
)
db.add(ev)
dav_util.bump_dav(cal, ev)
db.commit() db.commit()
db.refresh(ev) db.refresh(ev)
return _event_dict(ev, cal, db) return _event_dict(ev, cal, db)
@@ -507,12 +305,6 @@ def update_event(
ev.exdate = ",".join(dates) ev.exdate = ",".join(dates)
if data.reminders is not None: if data.reminders is not None:
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
if data.external_uid is not None:
ev.external_uid = data.external_uid
if data.birth_year is not None:
# -1 is the client's sentinel for "clear" (year became unknown).
ev.birth_year = None if data.birth_year < 0 else data.birth_year
dav_util.bump_dav(ev.calendar, ev)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -524,48 +316,11 @@ def delete_event(
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
ev = _writable_event(db, current_user, uid) ev = _writable_event(db, current_user, uid)
dav_util.bump_dav(ev.calendar)
db.delete(ev) db.delete(ev)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@router.get("/calendars/{calendar_id}/birthdays")
def list_birthday_entries(
calendar_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Raw (unexpanded) rows of a birthday calendar so a client importer can
reconcile against the address book by ``external_uid``. Contact-sourced rows
carry an ``external_uid``; manually added birthdays have ``None`` and must be
left untouched by the importer."""
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
events = (
db.query(models.LocalEvent)
.filter(models.LocalEvent.calendar_id == cal.id)
.all()
)
out = []
for ev in events:
month = day = None
try:
parts = (ev.start or "")[:10].split("-")
if len(parts) == 3:
month, day = int(parts[1]), int(parts[2])
except (ValueError, IndexError):
pass
out.append({
"uid": ev.uid,
"external_uid": ev.external_uid,
"title": ev.title,
"month": month,
"day": day,
"birth_year": ev.birth_year,
})
return out
# ── Sharing (owner only) ────────────────────────────────── # ── Sharing (owner only) ──────────────────────────────────
@router.get("/calendars/{calendar_id}/shares") @router.get("/calendars/{calendar_id}/shares")
@@ -686,12 +441,9 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
rrule=item.get("rrule"), rrule=item.get("rrule"),
exdate=item.get("exdate"), exdate=item.get("exdate"),
creator_name_external=item.get("organizer"), creator_name_external=item.get("organizer"),
etag=dav_util.new_tag(),
) )
db.add(ev) db.add(ev)
imported += 1 imported += 1
if imported:
dav_util.bump_dav(cal)
try: try:
db.commit() db.commit()
except Exception as exc: except Exception as exc:
-495
View File
@@ -1,495 +0,0 @@
"""OpenID Connect single sign-on — browser flow and native-client exchange.
Two consumers:
* **Web** — ``/start`` redirects to the provider, ``/callback`` receives the
code, ``/complete`` hands the Calendarr JWT to the SPA.
* **Mobile** — the app runs the whole flow itself as a *public* client
(AppAuth, PKCE, ``offline_access``) and trades the resulting ID token for a
Calendarr JWT at ``/exchange``. It never sends a client secret.
Flow state (PKCE verifier, nonce) lives in a short-lived signed JWT in an
HttpOnly cookie rather than in a process-local dict like
``homeassistant_router._pending_oauth`` — so it survives a restart and works
with more than one worker.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
from datetime import datetime, timedelta
from typing import Optional
from urllib.parse import urlencode
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
from jose import JWTError, jwt
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
import oidc_client
import oidc_config
from auth import ALGORITHM, SECRET_KEY, create_user_token, get_current_user
from database import get_db
from dav_util import public_base
from oidc_client import OIDCError
from oidc_identity import OIDCPolicyError, link_identity, resolve_user
logger = logging.getLogger(__name__)
router = APIRouter()
FLOW_COOKIE = "clr_oidc_flow"
FLOW_TTL = 600 # 10 minutes to complete a login
HANDOFF_COOKIE = "clr_oidc_token"
HANDOFF_TTL = 60 # the SPA picks the token up immediately
LINK_COOKIE = "clr_oidc_link"
LINK_TTL = 900 # 15 minutes to sign in with a password and link
# Refusals that mean "this identity is fine, it just has no owner yet". In all
# three the honest answer is to ask the user to prove which account is theirs,
# rather than to dead-end them: no account exists, one exists under the same
# address, or automatic creation is switched off.
LINKABLE_REFUSALS = {
"oidc_account_not_linked",
"oidc_signup_disabled",
"oidc_email_conflict",
}
LINK_RETURN = "/?sso_linked=1"
# ── flow-state cookie ────────────────────────────────────────────────────────
def _cookie_kwargs(request: Request, path: str, max_age: int) -> dict:
return {
"httponly": True,
# Lax, NOT Strict: the cookie has to survive the identity provider's
# top-level cross-site redirect back to /callback. With Strict the
# browser drops it and every single login fails with state_mismatch.
"samesite": "lax",
"secure": public_base(request).startswith("https"),
"path": path,
"max_age": max_age,
}
def _encode_flow(payload: dict) -> str:
data = dict(payload)
data["typ"] = "oidc_flow"
data["exp"] = datetime.utcnow() + timedelta(seconds=FLOW_TTL)
return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM)
def _encode_link(claims: dict, provider_key: str) -> str:
"""A verified-but-unlinked identity, parked until the user proves the
Calendarr account is theirs by signing in with their password."""
data = {
"typ": "oidc_link",
"p": provider_key,
"sub": claims.get("sub"),
"iss": claims.get("iss"),
"email": claims.get("email"),
"exp": datetime.utcnow() + timedelta(seconds=LINK_TTL),
}
return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM)
def _decode_link(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
return None
if payload.get("typ") != "oidc_link" or not payload.get("sub"):
return None
return payload
def _decode_flow(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
return None
# Guard against another token type being replayed here.
if payload.get("typ") != "oidc_flow":
return None
return payload
def _pkce_pair() -> tuple:
verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
def _redirect_uri(request: Request, provider) -> str:
"""Always derived server-side — never taken from client input."""
if provider.redirect_uri:
return provider.redirect_uri
return f"{public_base(request)}/api/auth/oidc/{provider.key}/callback"
def _fail(request: Request, slug: str) -> RedirectResponse:
resp = RedirectResponse(url=f"/?sso_error={slug}", status_code=302)
resp.delete_cookie(FLOW_COOKIE, path="/api/auth/oidc")
return resp
# ── discovery for clients ────────────────────────────────────────────────────
@router.get("/providers")
def list_providers():
"""Public: what the login screen and the mobile apps need to start a flow.
Never exposes the client secret.
"""
out = []
for provider in oidc_config.get_providers().values():
entry = {
"key": provider.key,
"name": provider.name,
"icon": provider.icon,
"issuer": provider.issuer,
"scopes": provider.scopes,
"start_url": f"/api/auth/oidc/{provider.key}/start",
}
if provider.mobile_client_ids:
entry["mobile_client_id"] = provider.mobile_client_ids[0]
# The apps request exactly these — they must not invent scopes of
# their own, or a provider that lacks one rejects the whole flow.
entry["mobile_scopes"] = provider.mobile_scopes
try:
doc = oidc_client.discover(provider)
entry["authorization_endpoint"] = doc.get("authorization_endpoint")
entry["token_endpoint"] = doc.get("token_endpoint")
entry["end_session_endpoint"] = doc.get("end_session_endpoint")
except Exception as e:
# A provider whose discovery is down must not break the login page.
logger.warning("OIDC discovery failed for %s: %s", provider.key, e)
entry["degraded"] = True
out.append(entry)
return {"enabled": bool(out), "providers": out}
# ── browser flow ─────────────────────────────────────────────────────────────
@router.get("/{provider_key}/start")
def oidc_start(provider_key: str, request: Request, db: Session = Depends(get_db)):
provider = oidc_config.get_provider(provider_key)
if provider is None:
raise HTTPException(404, "Unbekannter SSO-Anbieter")
# Before the first-run admin exists, an SSO login would create a non-admin
# account and leave the instance unadministrable.
if db.query(models.User).count() == 0:
return _fail(request, "setup_required")
try:
authorize_url = oidc_client.authorization_endpoint(provider)
except OIDCError as e:
logger.warning("OIDC start failed for %s: %s", provider.key, e.detail)
return _fail(request, e.slug)
verifier, challenge = _pkce_pair()
nonce = secrets.token_urlsafe(24)
state = secrets.token_urlsafe(16)
redirect_uri = _redirect_uri(request, provider)
params = {
"response_type": "code",
"client_id": provider.client_id,
"redirect_uri": redirect_uri,
"scope": provider.scopes,
"state": state,
"nonce": nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
resp = RedirectResponse(url=f"{authorize_url}?{urlencode(params)}", status_code=302)
# Only `state` travels over the wire; verifier and nonce stay in the cookie
# and therefore out of the provider's access logs.
resp.set_cookie(
FLOW_COOKIE,
_encode_flow({"p": provider.key, "n": nonce, "cv": verifier, "jti": state}),
**_cookie_kwargs(request, "/api/auth/oidc", FLOW_TTL),
)
return resp
@router.get("/{provider_key}/callback")
def oidc_callback(provider_key: str, request: Request,
code: str = Query(""), state: str = Query(""),
error: str = Query(""), db: Session = Depends(get_db)):
provider = oidc_config.get_provider(provider_key)
if provider is None:
return _fail(request, "unknown_provider")
if error or not code:
return _fail(request, error or "no_code")
raw = request.cookies.get(FLOW_COOKIE)
payload = _decode_flow(raw) if raw else None
# Double-submit: the state in the URL must match the one in the cookie, so
# an attacker cannot feed a victim a callback URL from their own flow.
if not payload or payload.get("p") != provider.key or payload.get("jti") != state:
return _fail(request, "state_mismatch")
try:
tokens = oidc_client.exchange_code(
provider, code, _redirect_uri(request, provider), payload["cv"])
claims = oidc_client.validate_id_token(
provider, tokens["id_token"],
expected_audience=provider.client_id,
nonce=payload.get("n"),
# We generated this nonce; the provider must echo it back.
require_nonce=True,
access_token=tokens.get("access_token"),
)
if not claims.get("email"):
claims.update({k: v for k, v in
oidc_client.fetch_userinfo(
provider, tokens.get("access_token") or "").items()
if k not in claims})
except OIDCError as e:
logger.warning("OIDC callback failed for %s: %s", provider.key, e.detail)
return _fail(request, e.slug)
try:
user, action = resolve_user(db, provider, claims)
except OIDCPolicyError as e:
if e.slug in LINKABLE_REFUSALS:
# The identity is verified but belongs to nobody yet. Park it and
# invite the user to prove the Calendarr account is theirs. That is
# strictly safer than matching on the email address alone, which
# trusts the provider not to hand out someone else's.
resp = RedirectResponse(url="/?sso_link=1", status_code=302)
resp.delete_cookie(FLOW_COOKIE, path="/api/auth/oidc")
resp.set_cookie(
LINK_COOKIE, _encode_link(claims, provider.key),
**_cookie_kwargs(request, "/api/auth/oidc", LINK_TTL),
)
return resp
return _fail(request, e.slug)
logger.info("OIDC login (%s) user id=%s via %s", action, user.id, provider.key)
token = create_user_token(user)
resp = RedirectResponse(url="/?sso=1", status_code=302)
resp.delete_cookie(FLOW_COOKIE, path="/api/auth/oidc")
# The token goes in an HttpOnly cookie, not in the URL: a query parameter
# would land in the reverse-proxy and uvicorn access logs and in Referer,
# and a fragment would persist in browser history.
resp.set_cookie(
HANDOFF_COOKIE, token,
**_cookie_kwargs(request, "/api/auth/oidc", HANDOFF_TTL),
)
return resp
@router.post("/complete")
def oidc_complete(request: Request, response: Response, db: Session = Depends(get_db)):
"""Trade the one-time handoff cookie for the token, same-origin."""
token = request.cookies.get(HANDOFF_COOKIE)
# Clear it either way: one attempt per login, success or not.
response.delete_cookie(HANDOFF_COOKIE, path="/api/auth/oidc")
if not token:
raise HTTPException(401, "Keine SSO-Sitzung")
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
uid = payload.get("uid")
except JWTError:
raise HTTPException(401, "SSO-Sitzung ungültig")
if uid is None:
raise HTTPException(401, "SSO-Sitzung ungültig")
user = db.query(models.User).filter(models.User.id == uid).first()
if not user:
raise HTTPException(401, "SSO-Sitzung ungültig")
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
@router.post("/link-pending")
def oidc_link_pending(request: Request, response: Response,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db)):
"""Attach the parked SSO identity to the account that just signed in.
Both halves are proven: we validated the ID token ourselves before parking
it, and the bearer token proves this password login succeeded.
"""
raw = request.cookies.get(LINK_COOKIE)
response.delete_cookie(LINK_COOKIE, path="/api/auth/oidc")
if not raw:
raise HTTPException(404, "Keine offene SSO-Verknüpfung")
payload = _decode_link(raw)
if not payload:
raise HTTPException(400, "oidc_link_expired")
provider = oidc_config.get_provider(payload.get("p", ""))
if provider is None:
raise HTTPException(400, "oidc_unknown_provider")
claims = {"sub": payload["sub"], "iss": payload.get("iss"),
"email": payload.get("email")}
try:
link_identity(db, current_user, provider, claims)
except OIDCPolicyError as e:
raise HTTPException(409, e.slug)
logger.info("OIDC: linked %s identity to user id=%s after password login",
provider.key, current_user.id)
return {"linked": True, "provider": provider.key, "name": provider.name}
# ── native (mobile) clients ──────────────────────────────────────────────────
class ExchangeRequest(BaseModel):
"""Deliberately has no client_secret field — public clients must not send one."""
provider: str
client_id: str
id_token: str
access_token: Optional[str] = None
nonce: Optional[str] = None
@router.post("/exchange")
def oidc_exchange(req: ExchangeRequest, db: Session = Depends(get_db)):
"""Trade a provider ID token for a Calendarr JWT (AppAuth iOS/Android).
The app has already completed the authorization-code flow with PKCE
against the provider. Its security rests on PKCE plus the provider's
registered redirect-URI check (RFC 8252), not on a secret.
"""
provider = oidc_config.get_provider(req.provider)
if provider is None:
raise HTTPException(401, "oidc_unknown_provider")
# Restrict to client ids we know: otherwise an ID token minted for an
# unrelated application at the same provider could be replayed here.
if req.client_id not in oidc_config.accepted_audiences(provider):
raise HTTPException(401, "oidc_unknown_client")
try:
claims = oidc_client.validate_id_token(
provider, req.id_token,
expected_audience=req.client_id,
nonce=req.nonce,
access_token=req.access_token,
)
except OIDCError as e:
logger.info("OIDC exchange rejected: %s", e.detail)
raise HTTPException(401, e.slug)
# The nonce is the app's replay protection; if the token carries one, the
# caller has to prove it knows it.
if claims.get("nonce") and not req.nonce:
raise HTTPException(400, "oidc_nonce_required")
if not claims.get("email") and req.access_token:
claims.update({k: v for k, v in
oidc_client.fetch_userinfo(provider, req.access_token).items()
if k not in claims})
try:
user, action = resolve_user(db, provider, claims)
except OIDCPolicyError as e:
raise HTTPException(403, e.slug)
logger.info("OIDC exchange (%s) user id=%s via %s", action, user.id, provider.key)
return {
"access_token": create_user_token(user),
"token_type": "bearer",
"user": _user_dict(user),
}
# ── linking from an authenticated session ────────────────────────────────────
@router.get("/identities")
def list_identities(current_user: models.User = Depends(get_current_user)):
return {
"auth_source": current_user.auth_source or "local",
"identities": [
{
"id": i.id,
"provider": i.provider_key,
"name": (oidc_config.get_provider(i.provider_key).name
if oidc_config.get_provider(i.provider_key) else i.provider_key),
"email": i.email,
"last_login_at": i.last_login_at.isoformat() if i.last_login_at else None,
}
for i in current_user.oidc_identities
],
}
@router.post("/{provider_key}/link")
def link_current_user(provider_key: str,
id_token: str = Body(..., embed=True),
client_id: str = Body("", embed=True),
nonce: Optional[str] = Body(None, embed=True),
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db)):
"""Attach an SSO identity to the signed-in account.
This is the safe alternative to matching on email: the user proves they
control both the Calendarr account (bearer token) and the SSO identity.
"""
provider = oidc_config.get_provider(provider_key)
if provider is None:
raise HTTPException(404, "Unbekannter SSO-Anbieter")
audience = client_id or provider.client_id
if audience not in oidc_config.accepted_audiences(provider):
raise HTTPException(400, "oidc_unknown_client")
try:
claims = oidc_client.validate_id_token(
provider, id_token, expected_audience=audience, nonce=nonce)
except OIDCError as e:
raise HTTPException(400, e.slug)
try:
identity = link_identity(db, current_user, provider, claims)
except OIDCPolicyError as e:
raise HTTPException(409, e.slug)
return {"linked": True, "id": identity.id, "provider": provider.key}
@router.delete("/identities/{identity_id}")
def unlink_identity(identity_id: int,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db)):
identity = db.query(models.OIDCIdentity).filter(
models.OIDCIdentity.id == identity_id,
models.OIDCIdentity.user_id == current_user.id,
).first()
if identity is None:
raise HTTPException(404, "Verknüpfung nicht gefunden")
# An SSO-provisioned user has no usable password, so removing their last
# identity would lock them out permanently.
if (current_user.auth_source or "local") == "sso" and len(current_user.oidc_identities) <= 1:
raise HTTPException(400, "oidc_last_identity")
db.delete(identity)
db.commit()
return {"unlinked": True}
def _user_dict(user: models.User) -> dict:
"""Same shape as auth_router's login response, so clients share a path."""
return {
"id": user.id,
"username": user.username,
"display_name": user.display_name or user.username,
"is_admin": user.is_admin,
}
+3 -88
View File
@@ -1,8 +1,6 @@
import io import io
import re import re
import base64 import base64
import secrets
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -17,7 +15,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
import models import models
from auth import create_user_token, 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 from database import DATA_DIR, get_db
router = APIRouter() router = APIRouter()
@@ -34,7 +32,6 @@ class ProfileUpdate(BaseModel):
email: Optional[str] = Field(default=None, max_length=120) email: Optional[str] = Field(default=None, max_length=120)
display_name: Optional[str] = Field(default=None, max_length=80) display_name: Optional[str] = Field(default=None, max_length=80)
username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase) username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase)
directory_hidden: Optional[bool] = None # hide from sharing/group pickers
def _strip_controls(s: str) -> str: def _strip_controls(s: str) -> str:
@@ -67,7 +64,6 @@ def get_profile(current_user: models.User = Depends(get_current_user)):
"is_admin": current_user.is_admin, "is_admin": current_user.is_admin,
"has_avatar": current_user.avatar_filename is not None, "has_avatar": current_user.avatar_filename is not None,
"totp_enabled": current_user.totp_enabled, "totp_enabled": current_user.totp_enabled,
"directory_hidden": bool(current_user.directory_hidden),
} }
@@ -111,17 +107,11 @@ def update_profile(
if taken: if taken:
raise HTTPException(400, "Username already taken") raise HTTPException(400, "Username already taken")
current_user.username = new_login current_user.username = new_login
if data.directory_hidden is not None:
current_user.directory_hidden = data.directory_hidden
db.commit() db.commit()
# The JWT 'sub' is the login name — renaming it invalidates the old # The JWT 'sub' is the login name — renaming it invalidates the old
# token, so hand back a fresh one for the client to store. # token, so hand back a fresh one for the client to store.
# The session survives a rename on its own (uid), but the token result["access_token"] = create_access_token({"sub": new_login})
# still carries the old name for display — hand out a fresh one.
result["access_token"] = create_user_token(current_user)
return result return result
if data.directory_hidden is not None:
current_user.directory_hidden = data.directory_hidden
db.commit() db.commit()
return result return result
@@ -178,11 +168,7 @@ def get_avatar(current_user: models.User = Depends(get_current_user)):
@router.get("/avatar/{user_id}") @router.get("/avatar/{user_id}")
def get_user_avatar( def get_user_avatar(user_id: int, db: Session = Depends(get_db)):
user_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
user = db.query(models.User).filter(models.User.id == user_id).first() user = db.query(models.User).filter(models.User.id == user_id).first()
if not user or not user.avatar_filename: if not user or not user.avatar_filename:
raise HTTPException(404, "Kein Profilbild") raise HTTPException(404, "Kein Profilbild")
@@ -278,74 +264,3 @@ def disable_totp(
current_user.totp_enabled = False current_user.totp_enabled = False
db.commit() db.commit()
return {"ok": True} return {"ok": True}
# ── App passwords (for CalDAV Basic Auth) ────────────────
class AppPasswordCreate(BaseModel):
label: str = Field(default="CalDAV", max_length=100)
def _app_pw_dict(ap: models.AppPassword) -> dict:
return {
"id": ap.id,
"label": ap.label,
"created_at": ap.created_at,
"last_used_at": ap.last_used_at,
}
@router.get("/app-passwords")
def list_app_passwords(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
rows = (
db.query(models.AppPassword)
.filter(models.AppPassword.user_id == current_user.id)
.order_by(models.AppPassword.id.desc())
.all()
)
return [_app_pw_dict(r) for r in rows]
@router.post("/app-passwords")
def create_app_password(
data: AppPasswordCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
# Show the plaintext exactly once; only the hash is stored.
password = secrets.token_urlsafe(18)
ap = models.AppPassword(
user_id=current_user.id,
label=(data.label or "CalDAV")[:100],
password_hash=get_password_hash(password),
created_at=datetime.now(timezone.utc).isoformat(),
)
db.add(ap)
db.commit()
db.refresh(ap)
out = _app_pw_dict(ap)
out["password"] = password
return out
@router.delete("/app-passwords/{ap_id}")
def delete_app_password(
ap_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ap = (
db.query(models.AppPassword)
.filter(
models.AppPassword.id == ap_id,
models.AppPassword.user_id == current_user.id,
)
.first()
)
if not ap:
raise HTTPException(404, "App password not found")
db.delete(ap)
db.commit()
return {"ok": True}
+1 -105
View File
@@ -1,4 +1,3 @@
import json
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -12,56 +11,6 @@ from database import get_db
router = APIRouter() router = APIRouter()
# Which settings can sync across a user's devices, and the default flag applied to
# an existing/new account until the user overrides it. This map is the single
# authority: GET returns the fully-resolved flags so no client duplicates default
# logic. Rollout rule: settings that already lived on the server default to True;
# the four newly-syncable device-local prefs default to False.
DEFAULT_SYNC = {
"default_view": True,
"week_start_day": True,
"dim_past_events": True,
"hour_height": True,
"primary_color": True,
"accent_color": True,
"today_color": True,
"text_color": True,
"line_color": True,
"bg_color": True,
"month_divider_color": True,
"month_label_color": True,
"default_event_duration_minutes": True,
"default_reminder_minutes": True,
"language": False,
"share_calendar_icon": False,
"cache_months": False,
"month_view_paged": False,
"surface_color": False,
"hover_highlight_color": True,
"icon_inactive_color": True,
"icon_active_color": True,
"day_hover_color": True,
"day_selected_color": True,
"day_bg_color": True,
"today_bg_color": True,
}
def _resolve_sync_flags(s: models.UserSettings) -> dict:
"""Fully-resolved {key: bool} for every syncable setting: stored overrides on
top of DEFAULT_SYNC, junk keys dropped."""
stored = {}
if s.sync_flags:
try:
stored = json.loads(s.sync_flags) or {}
except (ValueError, TypeError):
stored = {}
return {
key: bool(stored[key]) if key in stored else default
for key, default in DEFAULT_SYNC.items()
}
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
default_view: Optional[str] = None default_view: Optional[str] = None
week_start_day: Optional[str] = None week_start_day: Optional[str] = None
@@ -78,22 +27,11 @@ class SettingsUpdate(BaseModel):
text_color: Optional[str] = None text_color: Optional[str] = None
line_color: Optional[str] = None line_color: Optional[str] = None
bg_color: Optional[str] = None bg_color: Optional[str] = None
surface_color: Optional[str] = None
hover_highlight_color: Optional[str] = None
icon_inactive_color: Optional[str] = None
icon_active_color: Optional[str] = None
day_hover_color: Optional[str] = None
day_selected_color: Optional[str] = None
day_bg_color: Optional[str] = None
today_bg_color: Optional[str] = None
private_event_visibility: Optional[str] = None private_event_visibility: Optional[str] = None
group_visible_calendar_id: Optional[int] = None group_visible_calendar_id: Optional[int] = None
default_reminder_minutes: Optional[int] = None # null = off default_reminder_minutes: Optional[int] = None # null = off
default_event_duration_minutes: Optional[int] = None default_event_duration_minutes: Optional[int] = None
share_calendar_icon: Optional[str] = None share_calendar_icon: Optional[str] = None
cache_months: Optional[int] = None
month_view_paged: Optional[bool] = None
sync_flags: Optional[dict] = None # partial {key: bool}, merged into stored map
def _settings_dict(s: models.UserSettings) -> dict: def _settings_dict(s: models.UserSettings) -> dict:
@@ -113,22 +51,11 @@ def _settings_dict(s: models.UserSettings) -> dict:
"text_color": s.text_color, "text_color": s.text_color,
"line_color": s.line_color, "line_color": s.line_color,
"bg_color": s.bg_color, "bg_color": s.bg_color,
"surface_color": s.surface_color,
"hover_highlight_color": s.hover_highlight_color,
"icon_inactive_color": s.icon_inactive_color,
"icon_active_color": s.icon_active_color,
"day_hover_color": s.day_hover_color,
"day_selected_color": s.day_selected_color,
"day_bg_color": s.day_bg_color,
"today_bg_color": s.today_bg_color,
"private_event_visibility": s.private_event_visibility or "busy", "private_event_visibility": s.private_event_visibility or "busy",
"group_visible_calendar_id": s.group_visible_calendar_id, "group_visible_calendar_id": s.group_visible_calendar_id,
"default_reminder_minutes": s.default_reminder_minutes, "default_reminder_minutes": s.default_reminder_minutes,
"default_event_duration_minutes": s.default_event_duration_minutes or 60, "default_event_duration_minutes": s.default_event_duration_minutes or 60,
"share_calendar_icon": s.share_calendar_icon, "share_calendar_icon": s.share_calendar_icon,
"cache_months": s.cache_months or 3,
"month_view_paged": bool(s.month_view_paged),
"sync_flags": _resolve_sync_flags(s),
} }
@@ -168,42 +95,11 @@ def update_settings(
if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"): 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'") raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'")
# A birthday calendar must never become the group-visible ("personal")
# calendar — it may be shared directly, but not stand in as your calendar in
# group views. Clients filter it out of the picker; this is the safety net.
if data.group_visible_calendar_id:
bcal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == data.group_visible_calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if bcal is not None and bcal.is_birthday:
raise HTTPException(422, "A birthday calendar can't be your group-visible calendar")
# For these three override colours, an explicit null is meaningful # For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields # ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored. # keep the previous behaviour where a null/missing value is ignored.
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "hover_highlight_color", "icon_inactive_color", "icon_active_color", "day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"} NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"}
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
# syncable keys are kept; a partial map leaves untouched flags as they were.
if "sync_flags" in update_data:
incoming = update_data.pop("sync_flags") or {}
current = {}
if settings.sync_flags:
try:
current = json.loads(settings.sync_flags) or {}
except (ValueError, TypeError):
current = {}
for key, val in incoming.items():
if key in DEFAULT_SYNC:
current[key] = bool(val)
settings.sync_flags = json.dumps(current)
for field, value in update_data.items(): for field, value in update_data.items():
if field in NULLABLE_OVERRIDES: if field in NULLABLE_OVERRIDES:
setattr(settings, field, value or None) setattr(settings, field, value or None)
+1 -30
View File
@@ -23,10 +23,6 @@ class ChangePasswordRequest(BaseModel):
password: str password: str
class SetAdminRequest(BaseModel):
is_admin: bool
def _user_dict(u: models.User) -> dict: def _user_dict(u: models.User) -> dict:
return { return {
"id": u.id, "id": u.id,
@@ -57,10 +53,7 @@ def user_directory(
""" """
users = ( users = (
db.query(models.User) db.query(models.User)
.filter( .filter(models.User.id != current_user.id)
models.User.id != current_user.id,
models.User.directory_hidden == False, # noqa: E712 — hidden users opt out of pickers
)
.order_by(models.User.username) .order_by(models.User.username)
.all() .all()
) )
@@ -106,28 +99,6 @@ def delete_user(
return {"ok": True} return {"ok": True}
@router.put("/{user_id}/admin")
def set_admin(
user_id: int,
req: SetAdminRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_admin),
):
if user_id == current_user.id:
raise HTTPException(400, "Cannot change your own admin status")
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(404, "User not found")
# Never leave the instance without an admin.
if user.is_admin and not req.is_admin:
admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712
if admin_count <= 1:
raise HTTPException(400, "At least one admin must remain")
user.is_admin = req.is_admin
db.commit()
return {"ok": True}
@router.put("/{user_id}/password") @router.put("/{user_id}/password")
def change_password( def change_password(
user_id: int, user_id: int,
-103
View File
@@ -409,106 +409,3 @@ def test_import_export_only_local(client):
cal_id = _make_calendar(client, admin, "Privat") cal_id = _make_calendar(client, admin, "Privat")
# Bob has no access -> 404 on export. # Bob has no access -> 404 on export.
assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404 assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404
# ── Group-visible calendars propagate to co-members' sidebars ─────────────
def test_group_visible_propagates_to_member_sidebar(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]})
# Bob designates a calendar as group-visible; admin (co-member) should see it.
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")
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
shared = [c for c in cals if c["id"] == b_cal]
assert len(shared) == 1, shared
assert shared[0]["owned"] is False
assert shared[0]["shared_by"] == "bob"
assert shared[0]["permission"] == "read"
assert shared[0].get("group_shared") is True
# Its events appear in the normal merged read, read-only.
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
bob_ev = [e for e in events if e["title"] == "Bobs Termin"]
assert bob_ev and bob_ev[0].get("read_only") is True
def test_group_visible_absent_when_not_designated(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]})
b_cal = _make_calendar(client, b_tok, "Bobs Kalender") # never designated
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
assert not any(c["id"] == b_cal for c in cals)
def test_group_visible_not_duplicated_with_direct_share(client):
admin = register_admin(client)
admin_id = client.get("/api/profile/", headers=auth(admin)).json()["id"]
b_id, b_tok = create_user(client, admin, "bob")
client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_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})
# Also directly shared with admin (read_write) — must not double-list.
client.post(f"/api/local/calendars/{b_cal}/shares", headers=auth(b_tok),
json={"user_id": admin_id, "permission": "read_write"})
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
matches = [c for c in cals if c["id"] == b_cal]
assert len(matches) == 1, matches
# The direct share wins (read_write, not flagged group_shared).
assert matches[0]["permission"] == "read_write"
assert matches[0].get("group_shared") is not True
# ── Hidden profile (directory opt-out) ────────────────────────────────────
def test_directory_hidden_excludes_from_picker_but_not_admin(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
assert any(u["id"] == b_id for u in
client.get("/api/users/directory", headers=auth(admin)).json())
r = client.put("/api/profile/", headers=auth(b_tok), json={"directory_hidden": True})
assert r.status_code == 200, r.text
assert not any(u["id"] == b_id for u in
client.get("/api/users/directory", headers=auth(admin)).json())
# Admin user management still lists the hidden user.
assert any(u["id"] == b_id for u in
client.get("/api/users/", headers=auth(admin)).json())
def test_combined_view_read_only_for_other_members(client):
"""In the group combined view, events I may not edit carry read_only=True:
other members' calendars are read-only; the group calendar + my own aren't."""
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"]
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")
# As admin: bob's event is read-only; the group calendar is editable.
by = {e["title"]: e for e in
client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]}
assert by["Bobs Termin"].get("read_only") is True
assert by["Gruppentermin"].get("read_only") is not True
# As bob: his own event and the group calendar are both editable.
by_b = {e["title"]: e for e in
client.get(f"/api/groups/{gid}/combined", headers=auth(b_tok), params=RANGE).json()["events"]}
assert by_b["Bobs Termin"].get("read_only") is not True
assert by_b["Gruppentermin"].get("read_only") is not True
-779
View File
@@ -1,779 +0,0 @@
"""OpenID Connect SSO — protocol validation, account mapping and provisioning.
ID tokens here are signed with a real RSA key and go through the real
validation path; only the network (discovery, JWKS, token endpoint) is stubbed,
at the single seam ``oidc_client._http_get``.
"""
import time
import pytest
from authlib.jose import JsonWebKey, jwt as authlib_jwt
import models
import oidc_client
import oidc_config
from conftest import auth, create_user, register_admin
from database import SessionLocal
ISSUER = "https://authentik.example.com/application/o/calendarr/"
WEB_CLIENT = "calendarr-web"
MOBILE_CLIENT = "calendarr-mobile"
# One key pair for the whole module — generating RSA keys is slow.
_KEY = JsonWebKey.generate_key("RSA", 2048, is_private=True)
_KID = "test-key-1"
def _jwks() -> dict:
pub = _KEY.as_dict(is_private=False)
pub["kid"] = _KID
return {"keys": [pub]}
def _discovery() -> dict:
return {
"issuer": ISSUER,
"authorization_endpoint": ISSUER + "authorize/",
"token_endpoint": ISSUER + "token/",
"userinfo_endpoint": ISSUER + "userinfo/",
"jwks_uri": ISSUER + "jwks/",
"end_session_endpoint": ISSUER + "end-session/",
}
def make_id_token(*, sub="ak-sub-1", aud=WEB_CLIENT, iss=ISSUER, nonce=None,
email="user@example.com", email_verified=True,
preferred_username="user", name="Test User",
exp_delta=300, alg="RS256", key=None, extra=None):
now = int(time.time())
payload = {
"iss": iss, "sub": sub, "aud": aud,
"iat": now, "exp": now + exp_delta,
}
if nonce is not None:
payload["nonce"] = nonce
if email is not None:
payload["email"] = email
payload["email_verified"] = email_verified
if preferred_username is not None:
payload["preferred_username"] = preferred_username
if name is not None:
payload["name"] = name
if extra:
payload.update(extra)
header = {"alg": alg, "kid": _KID}
return authlib_jwt.encode(header, payload, key or _KEY).decode("ascii")
@pytest.fixture
def stub_network(monkeypatch):
"""Serve discovery and JWKS locally; fail loudly on any other URL."""
def fake_get(url, timeout=10):
if url.endswith("/.well-known/openid-configuration"):
return _discovery()
if url == _discovery()["jwks_uri"]:
return _jwks()
raise AssertionError(f"unexpected network call: {url}")
monkeypatch.setattr(oidc_client, "_http_get", fake_get)
@pytest.fixture
def authentik_env(monkeypatch):
"""Provider configuration only — the network is left alone."""
monkeypatch.setenv("OIDC_PROVIDERS", "authentik")
monkeypatch.setenv("OIDC_AUTHENTIK_NAME", "Authentik")
monkeypatch.setenv("OIDC_AUTHENTIK_ISSUER", ISSUER)
monkeypatch.setenv("OIDC_AUTHENTIK_CLIENT_ID", WEB_CLIENT)
monkeypatch.setenv("OIDC_AUTHENTIK_MOBILE_CLIENT_ID", MOBILE_CLIENT)
oidc_config.reset_cache()
oidc_client.reset_cache()
yield
oidc_config.reset_cache()
oidc_client.reset_cache()
@pytest.fixture
def authentik(authentik_env, stub_network):
"""Configured provider with a working, stubbed IdP."""
yield
def exchange(client, **over):
body = {"provider": "authentik", "client_id": MOBILE_CLIENT,
"id_token": make_id_token(aud=MOBILE_CLIENT)}
body.update(over)
return client.post("/api/auth/oidc/exchange", json=body)
def db_session():
return SessionLocal()
# ── unconfigured: SSO must be completely invisible ───────────────────────────
def test_disabled_when_unconfigured(client):
oidc_config.reset_cache()
r = client.get("/api/auth/oidc/providers")
assert r.status_code == 200
assert r.json() == {"enabled": False, "providers": []}
assert client.get("/api/auth/oidc/authentik/start",
follow_redirects=False).status_code == 404
def test_password_login_still_works_with_sso_enabled(client, authentik):
register_admin(client, "admin", "pw")
r = client.post("/api/auth/login", json={"username": "admin", "password": "pw"})
assert r.status_code == 200
assert r.json()["user"]["username"] == "admin"
# ── /providers ───────────────────────────────────────────────────────────────
def test_providers_lists_endpoints(client, authentik):
body = client.get("/api/auth/oidc/providers").json()
assert body["enabled"] is True
p = body["providers"][0]
assert p["key"] == "authentik"
assert p["name"] == "Authentik"
assert p["mobile_client_id"] == MOBILE_CLIENT
assert p["authorization_endpoint"] == _discovery()["authorization_endpoint"]
def test_mobile_scopes_include_offline_access_by_default(client, authentik):
"""The apps need a refresh token, so the server asks for offline_access."""
p = client.get("/api/auth/oidc/providers").json()["providers"][0]
assert "offline_access" in p["mobile_scopes"].split()
# The browser flow does not need it and must not be changed by this.
assert "offline_access" not in p["scopes"].split()
def test_mobile_scopes_are_overridable(client, authentik, monkeypatch):
"""A provider that cannot grant offline_access must be configurable."""
monkeypatch.setenv("OIDC_AUTHENTIK_MOBILE_SCOPES", "openid email")
oidc_config.reset_cache()
p = client.get("/api/auth/oidc/providers").json()["providers"][0]
assert p["mobile_scopes"] == "openid email"
def test_providers_never_leaks_client_secret(client, authentik, monkeypatch):
secret = "super-secret-value-42"
monkeypatch.setenv("OIDC_AUTHENTIK_CLIENT_SECRET", secret)
oidc_config.reset_cache()
r = client.get("/api/auth/oidc/providers")
assert secret not in r.text
def test_provider_without_client_id_is_dropped(client, monkeypatch):
monkeypatch.setenv("OIDC_PROVIDERS", "broken")
monkeypatch.setenv("OIDC_BROKEN_ISSUER", ISSUER)
oidc_config.reset_cache()
assert client.get("/api/auth/oidc/providers").json()["enabled"] is False
oidc_config.reset_cache()
# ── browser flow: /start and /callback ───────────────────────────────────────
def test_start_redirects_with_pkce_and_sets_flow_cookie(client, authentik):
register_admin(client, "admin", "pw")
r = client.get("/api/auth/oidc/authentik/start", follow_redirects=False)
assert r.status_code == 302
loc = r.headers["location"]
assert loc.startswith(_discovery()["authorization_endpoint"])
for expected in ("code_challenge=", "code_challenge_method=S256",
"state=", "nonce=", "response_type=code"):
assert expected in loc
# The PKCE verifier must never travel to the provider.
assert "code_verifier" not in loc
cookie_header = r.headers["set-cookie"]
assert "clr_oidc_flow=" in cookie_header
assert "HttpOnly" in cookie_header
# Lax, not Strict: the cookie has to survive the IdP's cross-site redirect.
assert "SameSite=lax" in cookie_header.replace("samesite", "SameSite")
def test_start_refused_before_initial_setup(client, authentik):
r = client.get("/api/auth/oidc/authentik/start", follow_redirects=False)
assert r.status_code == 302
assert "sso_error=setup_required" in r.headers["location"]
def _break_network(monkeypatch):
"""Fail at the `requests` layer, below _http_get, so the real error
translation in _http_get is what gets exercised."""
import requests as real_requests
def boom(*a, **k):
raise real_requests.ConnectionError("dns go boom")
monkeypatch.setattr(oidc_client.http_requests, "get", boom)
monkeypatch.setattr(oidc_client.http_requests, "post", boom)
oidc_client.reset_cache()
def test_start_survives_unreachable_provider(client, authentik_env, monkeypatch):
"""An unreachable IdP must be a clean error, not an HTTP 500."""
register_admin(client, "admin", "pw")
_break_network(monkeypatch)
r = client.get("/api/auth/oidc/authentik/start", follow_redirects=False)
assert r.status_code == 302
assert "sso_error=oidc_provider_unreachable" in r.headers["location"]
def test_providers_stays_up_when_discovery_is_down(client, authentik_env, monkeypatch):
"""The login screen must render even if the IdP is unreachable."""
_break_network(monkeypatch)
body = client.get("/api/auth/oidc/providers").json()
assert body["enabled"] is True
assert body["providers"][0]["degraded"] is True
def test_exchange_survives_unreachable_jwks(client, authentik_env, monkeypatch):
"""A JWKS fetch failure must be a 401, not an HTTP 500."""
_seed_identity(client)
_break_network(monkeypatch)
assert exchange(client).status_code == 401
def test_callback_rejects_missing_cookie(client, authentik):
register_admin(client, "admin", "pw")
r = client.get("/api/auth/oidc/authentik/callback?code=x&state=y",
follow_redirects=False)
assert "sso_error=state_mismatch" in r.headers["location"]
def test_callback_rejects_state_mismatch(client, authentik, monkeypatch):
register_admin(client, "admin", "pw")
client.get("/api/auth/oidc/authentik/start", follow_redirects=False)
r = client.get("/api/auth/oidc/authentik/callback?code=x&state=tampered",
follow_redirects=False)
assert "sso_error=state_mismatch" in r.headers["location"]
with db_session() as db:
assert db.query(models.User).count() == 1 # only the admin
def test_callback_happy_path_logs_in_linked_user(client, authentik, monkeypatch):
admin_token = register_admin(client, "admin", "pw")
uid, _ = create_user(client, admin_token, "alice")
start = client.get("/api/auth/oidc/authentik/start", follow_redirects=False)
state = _state_from(start.headers["location"])
with db_session() as db:
db.add(models.OIDCIdentity(user_id=uid, provider_key="authentik",
issuer=ISSUER, subject="ak-alice"))
db.commit()
nonce = _nonce_from(start.headers["location"])
monkeypatch.setattr(oidc_client, "exchange_code", lambda *a, **k: {
"id_token": make_id_token(sub="ak-alice", nonce=nonce, email="alice@example.com"),
})
cb = client.get(f"/api/auth/oidc/authentik/callback?code=good&state={state}",
follow_redirects=False)
assert cb.status_code == 302
assert cb.headers["location"] == "/?sso=1"
assert "clr_oidc_token=" in cb.headers["set-cookie"]
done = client.post("/api/auth/oidc/complete")
assert done.status_code == 200
assert done.json()["user"]["username"] == "alice"
# The handoff cookie is single-use.
assert client.post("/api/auth/oidc/complete").status_code == 401
def _state_from(location: str) -> str:
from urllib.parse import parse_qs, urlparse
return parse_qs(urlparse(location).query)["state"][0]
def _nonce_from(location: str) -> str:
from urllib.parse import parse_qs, urlparse
return parse_qs(urlparse(location).query)["nonce"][0]
def _run_callback(client, monkeypatch, *, sub, email):
"""Drive /start + /callback for an identity, returning the callback response."""
start = client.get("/api/auth/oidc/authentik/start", follow_redirects=False)
state = _state_from(start.headers["location"])
nonce = _nonce_from(start.headers["location"])
monkeypatch.setattr(oidc_client, "exchange_code", lambda *a, **k: {
"id_token": make_id_token(sub=sub, nonce=nonce, email=email),
})
return client.get(f"/api/auth/oidc/authentik/callback?code=good&state={state}",
follow_redirects=False)
def test_unlinked_identity_offers_linking_instead_of_failing(client, authentik, monkeypatch):
"""An unknown SSO identity parks itself rather than dead-ending."""
admin_token = register_admin(client, "admin", "pw")
create_user(client, admin_token, "erin")
cb = _run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
assert cb.status_code == 302
assert cb.headers["location"] == "/?sso_link=1"
assert "clr_oidc_link=" in cb.headers["set-cookie"]
# Nothing is linked until an account proves itself.
with db_session() as db:
assert db.query(models.OIDCIdentity).count() == 0
def test_password_login_redeems_the_parked_identity(client, authentik, monkeypatch):
admin_token = register_admin(client, "admin", "pw")
uid, _ = create_user(client, admin_token, "erin")
_run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
# The user signs in with their password; the client then redeems the cookie.
login = client.post("/api/auth/login", json={"username": "erin", "password": "pw"})
token = login.json()["access_token"]
linked = client.post("/api/auth/oidc/link-pending", headers=auth(token))
assert linked.status_code == 200, linked.text
assert linked.json()["linked"] is True
with db_session() as db:
identity = db.query(models.OIDCIdentity).one()
assert identity.user_id == uid
assert identity.subject == "ak-erin"
# From now on SSO alone gets them in.
again = _run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
assert again.headers["location"] == "/?sso=1"
def test_parked_identity_links_to_whoever_signs_in(client, authentik, monkeypatch):
"""The link follows the password login, not the email in the token — that
is the whole point of asking instead of matching."""
admin_token = register_admin(client, "admin", "pw")
other_id, _ = create_user(client, admin_token, "frank")
_run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
login = client.post("/api/auth/login", json={"username": "frank", "password": "pw"})
client.post("/api/auth/oidc/link-pending", headers=auth(login.json()["access_token"]))
with db_session() as db:
assert db.query(models.OIDCIdentity).one().user_id == other_id
def test_link_pending_without_a_cookie_is_a_404(client, authentik):
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "erin")
assert client.post("/api/auth/oidc/link-pending", headers=auth(token)).status_code == 404
def test_link_cookie_is_single_use(client, authentik, monkeypatch):
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "erin")
_run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
assert client.post("/api/auth/oidc/link-pending", headers=auth(token)).status_code == 200
assert client.post("/api/auth/oidc/link-pending", headers=auth(token)).status_code == 404
def test_link_pending_requires_authentication(client, authentik, monkeypatch):
register_admin(client, "admin", "pw")
_run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
assert client.post("/api/auth/oidc/link-pending").status_code == 401
# ── /exchange: token validation (the mobile entry point) ─────────────────────
def _seed_identity(client, sub="ak-sub-1", username="alice"):
admin_token = register_admin(client, "admin", "pw")
uid, _ = create_user(client, admin_token, username)
with db_session() as db:
db.add(models.OIDCIdentity(user_id=uid, provider_key="authentik",
issuer=ISSUER, subject=sub))
db.commit()
return uid
def test_exchange_happy_path(client, authentik):
_seed_identity(client)
r = exchange(client)
assert r.status_code == 200, r.text
token = r.json()["access_token"]
me = client.get("/api/auth/me", headers=auth(token))
assert me.status_code == 200
assert me.json()["username"] == "alice"
def test_exchange_rejects_alg_none(client, authentik):
"""Regression guard for the pinned signature-algorithm allow-list."""
import base64
import json
def b64(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
header = b64(json.dumps({"alg": "none", "kid": _KID}).encode())
payload = b64(json.dumps({
"iss": ISSUER, "sub": "ak-sub-1", "aud": MOBILE_CLIENT,
"iat": int(time.time()), "exp": int(time.time()) + 300,
}).encode())
_seed_identity(client)
r = exchange(client, id_token=f"{header}.{payload}.")
assert r.status_code == 401
def test_exchange_rejects_bad_signature(client, authentik):
other_key = JsonWebKey.generate_key("RSA", 2048, is_private=True)
_seed_identity(client)
r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, key=other_key))
assert r.status_code == 401
def test_exchange_rejects_wrong_issuer(client, authentik):
_seed_identity(client)
r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT,
iss="https://evil.example.com/"))
assert r.status_code == 401
def test_exchange_rejects_wrong_audience(client, authentik):
_seed_identity(client)
# A token minted for the web client replayed against the mobile client id.
r = exchange(client, id_token=make_id_token(aud=WEB_CLIENT))
assert r.status_code == 401
def test_exchange_rejects_expired_token(client, authentik):
_seed_identity(client)
r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, exp_delta=-3600))
assert r.status_code == 401
def test_exchange_rejects_unknown_client_id(client, authentik):
_seed_identity(client)
r = exchange(client, client_id="some-other-app",
id_token=make_id_token(aud="some-other-app"))
assert r.status_code == 401
def test_exchange_rejects_nonce_mismatch(client, authentik):
_seed_identity(client)
r = exchange(client, nonce="expected-nonce",
id_token=make_id_token(aud=MOBILE_CLIENT, nonce="different"))
assert r.status_code == 401
def test_refreshed_token_without_nonce_is_accepted(client, authentik):
"""A token renewed via the refresh token drops the nonce claim; the app
still sends the nonce it remembers, and that must not be a mismatch."""
_seed_identity(client)
r = exchange(client, nonce="original-nonce",
id_token=make_id_token(aud=MOBILE_CLIENT, nonce=None))
assert r.status_code == 200, r.text
def test_browser_flow_requires_a_nonce_in_the_token(client, authentik):
"""We generate the nonce for the browser flow, so the provider must echo
it — a token without one is refused there even though /exchange allows it."""
import oidc_config
from oidc_client import OIDCError
provider = oidc_config.get_provider("authentik")
with pytest.raises(OIDCError) as e:
oidc_client.validate_id_token(
provider, make_id_token(nonce=None),
expected_audience=WEB_CLIENT, nonce="expected", require_nonce=True)
assert e.value.slug == "oidc_nonce_missing"
def test_exchange_requires_nonce_when_token_carries_one(client, authentik):
_seed_identity(client)
r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, nonce="from-appauth"))
assert r.status_code == 400
def test_exchange_accepts_no_secret_field(client, authentik):
"""A public client must not be able to authenticate with a secret."""
_seed_identity(client)
r = exchange(client, client_secret="anything") # extra field is ignored
assert r.status_code == 200
# ── account mapping and provisioning ─────────────────────────────────────────
def test_autoprovision_disabled_by_default(client, authentik):
register_admin(client, "admin", "pw")
r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="new-sub",
email="new@example.com"))
assert r.status_code == 403
assert r.json()["detail"] == "oidc_signup_disabled"
with db_session() as db:
assert db.query(models.User).count() == 1
def test_autoprovision_creates_user_with_settings(client, authentik, monkeypatch):
monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true")
oidc_config.reset_cache()
register_admin(client, "admin", "pw")
r = exchange(client, id_token=make_id_token(
aud=MOBILE_CLIENT, sub="new-sub", email="new@example.com",
preferred_username="Neuer Nutzer"))
assert r.status_code == 200, r.text
with db_session() as db:
user = db.query(models.User).filter(models.User.email == "new@example.com").one()
assert user.auth_source == "sso"
assert user.is_admin is False
assert user.username == "neuernutzer" # normalised
# Without this row /api/settings would 500 for the new user.
assert db.query(models.UserSettings).filter(
models.UserSettings.user_id == user.id).count() == 1
settings = client.get("/api/settings/", headers=auth(r.json()["access_token"]))
assert settings.status_code == 200
def test_domain_allowlist_blocks_foreign_domain(client, authentik, monkeypatch):
monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true")
monkeypatch.setenv("OIDC_AUTHENTIK_ALLOWED_DOMAINS", "allowed.example")
oidc_config.reset_cache()
register_admin(client, "admin", "pw")
r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="s2",
email="x@forbidden.example"))
assert r.status_code == 403
assert r.json()["detail"] == "oidc_domain_not_allowed"
ok = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="s3",
email="y@allowed.example"))
assert ok.status_code == 200
def test_email_match_on_existing_account_is_refused(client, authentik):
"""The account-takeover guard: no silent linking by email address."""
admin_token = register_admin(client, "admin", "pw")
client.post("/api/users/", headers=auth(admin_token),
json={"username": "bob", "password": "pw", "email": "bob@example.com"})
r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="ak-bob",
email="bob@example.com"))
assert r.status_code == 403
assert r.json()["detail"] == "oidc_account_not_linked"
with db_session() as db:
assert db.query(models.OIDCIdentity).count() == 0
def test_link_by_email_requires_verified_email(client, authentik, monkeypatch):
monkeypatch.setenv("OIDC_AUTHENTIK_LINK_BY_EMAIL", "true")
oidc_config.reset_cache()
admin_token = register_admin(client, "admin", "pw")
client.post("/api/users/", headers=auth(admin_token),
json={"username": "bob", "password": "pw", "email": "bob@example.com"})
bad = exchange(client, id_token=make_id_token(
aud=MOBILE_CLIENT, sub="ak-bob", email="bob@example.com", email_verified=False))
assert bad.status_code == 403
assert bad.json()["detail"] == "oidc_email_not_verified"
good = exchange(client, id_token=make_id_token(
aud=MOBILE_CLIENT, sub="ak-bob", email="bob@example.com", email_verified=True))
assert good.status_code == 200
assert good.json()["user"]["username"] == "bob"
def test_username_collision_gets_suffix(client, authentik, monkeypatch):
monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true")
oidc_config.reset_cache()
register_admin(client, "admin", "pw")
# A different human whose preferred_username happens to be "admin".
r = exchange(client, id_token=make_id_token(
aud=MOBILE_CLIENT, sub="other-human", email="other@example.com",
preferred_username="admin"))
assert r.status_code == 200
assert r.json()["user"]["username"] == "admin-2"
with db_session() as db:
assert db.query(models.User).filter(models.User.username == "admin").one().is_admin
def test_email_collision_during_signup_is_refused(client, authentik, monkeypatch):
monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true")
oidc_config.reset_cache()
admin_token = register_admin(client, "admin", "pw")
client.post("/api/users/", headers=auth(admin_token),
json={"username": "carol", "password": "pw", "email": "carol@example.com"})
r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="ak-carol",
email="carol@example.com"))
# Refused rather than forking one human into a second account.
assert r.status_code == 403
with db_session() as db:
assert db.query(models.User).filter(
models.User.email == "carol@example.com").count() == 1
def test_second_login_reuses_the_same_account(client, authentik, monkeypatch):
monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true")
oidc_config.reset_cache()
register_admin(client, "admin", "pw")
first = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="stable",
email="a@example.com"))
# Same sub, different email and username at the provider.
second = exchange(client, id_token=make_id_token(
aud=MOBILE_CLIENT, sub="stable", email="renamed@example.com",
preferred_username="renamed"))
assert first.json()["user"]["id"] == second.json()["user"]["id"]
with db_session() as db:
assert db.query(models.OIDCIdentity).count() == 1
# ── identity is the id, never the name ───────────────────────────────────────
def test_rename_at_the_provider_keeps_the_same_account(client, authentik, monkeypatch):
"""Authentik renames a user: same sub, new preferred_username and email.
That must land on the same Calendarr account, unchanged."""
monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true")
oidc_config.reset_cache()
register_admin(client, "admin", "pw")
first = exchange(client, id_token=make_id_token(
aud=MOBILE_CLIENT, sub="stable-sub", email="old@example.com",
preferred_username="oldname", name="Old Name"))
assert first.status_code == 200
uid = first.json()["user"]["id"]
original_username = first.json()["user"]["username"]
second = exchange(client, id_token=make_id_token(
aud=MOBILE_CLIENT, sub="stable-sub", email="brand-new@example.com",
preferred_username="totally-different", name="New Name"))
assert second.status_code == 200
assert second.json()["user"]["id"] == uid
# The local login name is ours, not the provider's — it does not churn.
assert second.json()["user"]["username"] == original_username
with db_session() as db:
assert db.query(models.OIDCIdentity).count() == 1
assert db.query(models.User).count() == 2 # admin + this one
def test_local_rename_keeps_existing_sessions_alive(client, authentik):
"""A username change must not silently sign the user out everywhere: the
token identifies the account by id."""
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "gina")
renamed = client.put("/api/profile/", headers=auth(token),
json={"username": "gina-married"})
assert renamed.status_code == 200, renamed.text
# The *old* token — as still held by another device — keeps working.
me = client.get("/api/auth/me", headers=auth(token))
assert me.status_code == 200
assert me.json()["username"] == "gina-married"
def test_local_rename_keeps_the_sso_link(client, authentik, monkeypatch):
"""Renaming locally must not detach a linked SSO identity either."""
admin_token = register_admin(client, "admin", "pw")
uid, token = create_user(client, admin_token, "gina")
with db_session() as db:
db.add(models.OIDCIdentity(user_id=uid, provider_key="authentik",
issuer=ISSUER, subject="ak-gina"))
db.commit()
client.put("/api/profile/", headers=auth(token), json={"username": "gina-married"})
r = exchange(client, id_token=make_id_token(sub="ak-gina", aud=MOBILE_CLIENT))
assert r.status_code == 200
assert r.json()["user"]["id"] == uid
def test_legacy_token_without_uid_still_works(client, authentik):
"""Tokens minted before the uid claim stay valid — "remember me" runs for
180 days, so they are in circulation for a long time."""
from auth import create_access_token
register_admin(client, "admin", "pw")
legacy = create_access_token({"sub": "admin"})
me = client.get("/api/auth/me", headers=auth(legacy))
assert me.status_code == 200
assert me.json()["username"] == "admin"
# ── the password_hash trap ───────────────────────────────────────────────────
def test_sso_user_password_login_fails_without_500(client, authentik, monkeypatch):
monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true")
oidc_config.reset_cache()
register_admin(client, "admin", "pw")
exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="s", email="s@example.com",
preferred_username="ssouser"))
r = client.post("/api/auth/login", json={"username": "ssouser", "password": ""})
assert r.status_code == 401, f"expected 401, got {r.status_code}"
def test_verify_password_survives_malformed_hash():
from auth import verify_password
for broken in ("", "!", "not-a-bcrypt-hash"):
assert verify_password("anything", broken) is False
# ── linking and unlinking from a session ─────────────────────────────────────
def test_link_and_unlink_identity(client, authentik):
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "dave")
r = client.post("/api/auth/oidc/authentik/link", headers=auth(token),
json={"id_token": make_id_token(sub="ak-dave"),
"client_id": WEB_CLIENT})
assert r.status_code == 200, r.text
identity_id = r.json()["id"]
listed = client.get("/api/auth/oidc/identities", headers=auth(token)).json()
assert len(listed["identities"]) == 1
assert listed["auth_source"] == "local"
# Now SSO login for that identity works.
ex = exchange(client, client_id=MOBILE_CLIENT,
id_token=make_id_token(sub="ak-dave", aud=MOBILE_CLIENT))
assert ex.status_code == 200
assert ex.json()["user"]["username"] == "dave"
assert client.delete(f"/api/auth/oidc/identities/{identity_id}",
headers=auth(token)).status_code == 200
def test_cannot_link_identity_owned_by_someone_else(client, authentik):
admin_token = register_admin(client, "admin", "pw")
_, dave = create_user(client, admin_token, "dave")
_, eve = create_user(client, admin_token, "eve")
client.post("/api/auth/oidc/authentik/link", headers=auth(dave),
json={"id_token": make_id_token(sub="ak-dave"), "client_id": WEB_CLIENT})
r = client.post("/api/auth/oidc/authentik/link", headers=auth(eve),
json={"id_token": make_id_token(sub="ak-dave"), "client_id": WEB_CLIENT})
assert r.status_code == 409
def test_sso_only_user_cannot_unlink_last_identity(client, authentik, monkeypatch):
monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true")
oidc_config.reset_cache()
register_admin(client, "admin", "pw")
r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="lonely",
email="lonely@example.com"))
token = r.json()["access_token"]
identities = client.get("/api/auth/oidc/identities", headers=auth(token)).json()
only = identities["identities"][0]["id"]
assert identities["auth_source"] == "sso"
resp = client.delete(f"/api/auth/oidc/identities/{only}", headers=auth(token))
assert resp.status_code == 400
assert resp.json()["detail"] == "oidc_last_identity"
# ── token type confusion ─────────────────────────────────────────────────────
def test_flow_token_cannot_be_used_as_access_token(client, authentik):
from routers.oidc_router import _encode_flow
register_admin(client, "admin", "pw")
flow = _encode_flow({"p": "authentik", "n": "n", "cv": "v", "jti": "s"})
assert client.get("/api/auth/me", headers=auth(flow)).status_code == 401
+28 -311
View File
@@ -23,17 +23,6 @@
--border-light: #242438; --border-light: #242438;
--scrollbar: #30303c; --scrollbar: #30303c;
/* Fine-grained element colours (customisable via Settings → Farben).
Defaults reference the derived values so the look is unchanged until the
user overrides them; applyTheme() writes concrete overrides on top. */
--hover-highlight: var(--bg-hover); /* general interactive hover */
--icon-inactive-color: var(--text-2); /* sidebar action icons: resting/off/not-hovered */
--icon-active-color: var(--text-1); /* sidebar action icons: hovered / on / pressed */
--day-hover-color: var(--bg-hover); /* calendar day-cell hover */
--day-selected-base: var(--primary); /* selected day (tinted via color-mix) */
--day-bg: transparent; /* normal day background */
--today-bg-base: var(--today-color); /* today's day (tinted via color-mix) */
--topbar-h: 64px; --topbar-h: 64px;
--sidebar-w: 256px; --sidebar-w: 256px;
--shadow: 0 2px 12px rgba(0,0,0,.45); --shadow: 0 2px 12px rgba(0,0,0,.45);
@@ -63,9 +52,6 @@ input, textarea, [contenteditable="true"], .selectable {
-webkit-user-select: text; user-select: text; -webkit-user-select: text; user-select: text;
} }
a { color: var(--primary); text-decoration: none; } a { color: var(--primary); text-decoration: none; }
/* Version im Impressum verlinkt aufs Gitea-Repo — dezent, erst beim Hover als Link erkennbar */
.impressum-version-link { color: var(--text-3); text-decoration: none; transition: color .15s ease; }
.impressum-version-link:hover { color: var(--primary); text-decoration: underline; }
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 3px; } ::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 3px; }
@@ -179,28 +165,6 @@ a { color: var(--primary); text-decoration: none; }
} }
.btn-fab:active { transform: translateY(0) scale(.985); } .btn-fab:active { transform: translateY(0) scale(.985); }
/* Split create button: one seamless pill — main "Erstellen" action on the left,
a caret section on the right that opens a small menu (new event / new birthday). */
.create-split {
display: flex; align-items: stretch; margin: 16px 12px 8px;
border-radius: 999px;
box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 35%, transparent);
}
.create-split .btn-fab { margin: 0; box-shadow: none; border-radius: 0; }
.create-split .btn-fab:hover { transform: none; box-shadow: none; filter: brightness(1.08); }
.create-split .create-main { flex: 1; justify-content: center; border-radius: 999px 0 0 999px; }
.create-split .create-caret {
flex: 0 0 auto; padding: 12px 14px; border-radius: 0 999px 999px 0;
border-left: 1px solid color-mix(in srgb, #fff 25%, transparent);
}
.create-split .create-caret svg { width: 18px; height: 18px; }
.create-menu { left: 0; right: auto; min-width: 200px; }
/* Birthday modal: day / month / year row */
.birthday-date-row { display: flex; gap: 8px; }
.birthday-date-row select { flex: 1; }
.birthday-date-row #birthday-year { width: 90px; flex: 0 0 auto; }
/* Circular icon buttons (topbar nav, modal close, etc.) */ /* Circular icon buttons (topbar nav, modal close, etc.) */
.icon-btn { .icon-btn {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
@@ -215,7 +179,7 @@ a { color: var(--primary); text-decoration: none; }
transform .1s ease; transform .1s ease;
} }
.icon-btn svg { width: 20px; height: 20px; fill: currentColor; } .icon-btn svg { width: 20px; height: 20px; fill: currentColor; }
.icon-btn:hover { background: var(--hover-highlight); color: var(--text-1); } .icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.icon-btn:active { transform: scale(.92); } .icon-btn:active { transform: scale(.92); }
.icon-btn:focus-visible { .icon-btn:focus-visible {
outline: 2px solid var(--primary); outline: 2px solid var(--primary);
@@ -324,7 +288,7 @@ a { color: var(--primary); text-decoration: none; }
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 9px 10px; border-radius: 8px; cursor: pointer; padding: 9px 10px; border-radius: 8px; cursor: pointer;
} }
.cal-select-option:hover { background: var(--hover-highlight); } .cal-select-option:hover { background: var(--bg-hover); }
.cal-select-option.selected { background: var(--primary-dim); } .cal-select-option.selected { background: var(--primary-dim); }
/* ── Date/time input dark mode ──────────────────────────── */ /* ── Date/time input dark mode ──────────────────────────── */
@@ -495,7 +459,7 @@ a { color: var(--primary); text-decoration: none; }
transition: background var(--transition), color var(--transition); transition: background var(--transition), color var(--transition);
border-radius: 20px; border-radius: 20px;
} }
.view-btn:hover { background: var(--hover-highlight); color: var(--text-1); } .view-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.view-btn.active { background: var(--primary-dim); color: var(--primary); } .view-btn.active { background: var(--primary-dim); color: var(--primary); }
.user-menu-wrapper { position: relative; } .user-menu-wrapper { position: relative; }
@@ -524,7 +488,7 @@ a { color: var(--primary); text-decoration: none; }
background: none; color: var(--text-2); font-size: 13px; background: none; color: var(--text-2); font-size: 13px;
cursor: pointer; transition: background var(--transition); cursor: pointer; transition: background var(--transition);
} }
.dropdown-item:hover { background: var(--hover-highlight); color: var(--text-1); } .dropdown-item:hover { background: var(--bg-hover); color: var(--text-1); }
.dropdown-item svg { flex-shrink: 0; .dropdown-item svg { flex-shrink: 0;
} }
@@ -564,37 +528,12 @@ a { color: var(--primary); text-decoration: none; }
.main-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; } .main-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
#view-container { flex: 1; display: flex; flex-direction: column; min-height: 0; } #view-container { flex: 1; display: flex; flex-direction: column; min-height: 0; }
/* ── SSO on the login screen ─────────────────────────────── */
/* Informational, not an error: the SSO identity is fine, it just needs to be
tied to an account. */
.form-hint {
font-size: 13px; line-height: 1.4;
padding: 10px 12px; margin-bottom: 12px;
border-radius: 8px;
color: var(--text-1);
background: var(--primary-dim);
border: 1px solid var(--primary);
}
#sso-block { margin-top: 18px; }
.auth-divider {
display: flex; align-items: center; gap: 10px;
margin: 0 0 14px; font-size: 12px; color: var(--text-3);
text-transform: uppercase; letter-spacing: .5px;
}
.auth-divider::before, .auth-divider::after {
content: ''; flex: 1; height: 1px; background: var(--border);
}
#sso-buttons { display: flex; flex-direction: column; gap: 8px; }
/* The SSO buttons are <a> elements (a top-level navigation is required to
follow the 302 and set the flow cookie), so undo the link defaults. */
.sso-btn { text-decoration: none; }
/* ── Mini Calendar ──────────────────────────────────────── */ /* ── Mini Calendar ──────────────────────────────────────── */
.mini-cal { padding: 12px 16px; } .mini-cal { padding: 12px 16px; }
.mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.mini-title { font-size: 13px; font-weight: 500; color: var(--text-1); } .mini-title { font-size: 13px; font-weight: 500; color: var(--text-1); }
.mini-btn { width: 28px; height: 28px; font-size: 18px; color: var(--text-2); } .mini-btn { width: 28px; height: 28px; font-size: 18px; color: var(--text-2); }
.mini-btn:hover { color: var(--text-1); background: var(--hover-highlight); } .mini-btn:hover { color: var(--text-1); background: var(--bg-hover); }
.mini-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; } .mini-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; }
.mini-dow { font-size: 11px; color: var(--text-3); padding: 2px 0; font-weight: 500; } .mini-dow { font-size: 11px; color: var(--text-3); padding: 2px 0; font-weight: 500; }
.mini-cal-days { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; } .mini-cal-days { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; }
@@ -605,13 +544,13 @@ a { color: var(--primary); text-decoration: none; }
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
margin: 1px auto; position: relative; margin: 1px auto; position: relative;
} }
.mini-day:hover { background: var(--day-hover-color); color: var(--text-1); } .mini-day:hover { background: var(--bg-hover); color: var(--text-1); }
.mini-day.other-month { color: var(--text-3); } .mini-day.other-month { color: var(--text-3); }
.mini-day.today { .mini-day.today {
background: var(--today-color); background: var(--today-color);
color: #fff; font-weight: 700; color: #fff; font-weight: 700;
} }
.mini-day.selected:not(.today) { background: color-mix(in srgb, var(--day-selected-base) 15%, transparent); color: var(--primary); font-weight: 600; } .mini-day.selected:not(.today) { background: var(--primary-dim); color: var(--primary); font-weight: 600; }
/* One dot per calendar with an event that day, in that calendar's colour. */ /* One dot per calendar with an event that day, in that calendar's colour. */
.mini-dots { .mini-dots {
position: absolute; bottom: 1px; left: 0; right: 0; position: absolute; bottom: 1px; left: 0; right: 0;
@@ -644,7 +583,7 @@ a { color: var(--primary); text-decoration: none; }
text-align: left; font-size: 13px; color: var(--text-1); text-align: left; font-size: 13px; color: var(--text-1);
background: none; border: none; cursor: pointer; background: none; border: none; cursor: pointer;
} }
.add-cal-dropdown button:hover { background: var(--hover-highlight); } .add-cal-dropdown button:hover { background: var(--bg-hover); }
.cal-item { .cal-item {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 6px 16px; cursor: pointer; padding: 6px 16px; cursor: pointer;
@@ -652,7 +591,7 @@ a { color: var(--primary); text-decoration: none; }
border-radius: 0 20px 20px 0; border-radius: 0 20px 20px 0;
margin-right: 12px; margin-right: 12px;
} }
.cal-item:hover { background: var(--hover-highlight); } .cal-item:hover { background: var(--bg-hover); }
.cal-item-dot { .cal-item-dot {
width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; cursor: pointer; width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; cursor: pointer;
} }
@@ -669,16 +608,11 @@ a { color: var(--primary); text-decoration: none; }
.cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; } .cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; }
/* Fixed min-height so 28px mini-btns never change row height when they appear. */ /* Fixed min-height so 28px mini-btns never change row height when they appear. */
.cal-item { position: relative; min-height: 40px; } .cal-item { position: relative; min-height: 40px; }
/* Sidebar action icons (bell / hide / delete / read-only flag) share two theme .cal-item-bell { display: none; flex-shrink: 0; }
colours: "inactive" (resting/off) and "active" (hovered/on/pressed). */
.cal-item-bell { display: none; flex-shrink: 0; color: var(--icon-active-color); } /* bell shown = reminders on = active */
.cal-item:hover .cal-item-remove, .cal-item:hover .cal-item-remove,
.cal-item:hover .cal-item-bell { display: inline-flex; } .cal-item:hover .cal-item-bell { display: inline-flex; }
.cal-item-bell.off { display: inline-flex; opacity: .55; color: var(--icon-inactive-color); } .cal-item-bell.off { display: inline-flex; opacity: .55; color: var(--text-3); }
.cal-item:hover .cal-item-bell.off { opacity: 1; color: var(--icon-inactive-color); } .cal-item:hover .cal-item-bell.off { opacity: 1; color: inherit; }
.cal-item-bell:hover, .cal-item-bell.off:hover { color: var(--icon-active-color); }
.cal-item-remove { color: var(--icon-inactive-color); }
.cal-item-remove:hover { color: var(--icon-active-color); }
/* ── Month View ─────────────────────────────────────────── */ /* ── Month View ─────────────────────────────────────────── */
.month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } .month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
@@ -711,12 +645,11 @@ a { color: var(--primary); text-decoration: none; }
flex: 1; border-right: 1px solid var(--border); flex: 1; border-right: 1px solid var(--border);
cursor: pointer; transition: background var(--transition); cursor: pointer; transition: background var(--transition);
padding: 4px 4px 0; min-width: 0; padding: 4px 4px 0; min-width: 0;
background: var(--day-bg);
} }
.month-col:last-child { border-right: none; } .month-col:last-child { border-right: none; }
.month-col:hover { background: var(--day-hover-color); } .month-col:hover { background: var(--bg-hover); }
.month-col.today { background: color-mix(in srgb, var(--today-bg-base) 10%, transparent); } .month-col.today { background: color-mix(in srgb, var(--today-color) 10%, transparent); }
.month-col.month-selected { background: color-mix(in srgb, var(--day-selected-base) 15%, transparent); } .month-col.month-selected { background: var(--primary-dim); }
.month-col.month-selected .cell-day { border: 2px solid var(--primary); color: var(--primary); font-weight: 700; } .month-col.month-selected .cell-day { border: 2px solid var(--primary); color: var(--primary); font-weight: 700; }
.month-col.month-selected .cell-day.today { background: var(--today-color); color: #fff; border: none; } .month-col.month-selected .cell-day.today { background: var(--today-color); color: #fff; border: none; }
.month-col.other-month .cell-day { color: var(--text-3); } .month-col.other-month .cell-day { color: var(--text-3); }
@@ -837,7 +770,7 @@ a { color: var(--primary); text-decoration: none; }
padding: 4px 12px; cursor: pointer; padding: 4px 12px; cursor: pointer;
border-radius: 6px; margin: 0 4px; border-radius: 6px; margin: 0 4px;
} }
.mop-row:hover { background: var(--hover-highlight); } .mop-row:hover { background: var(--bg-hover); }
.mop-dot { .mop-dot {
width: 8px; height: 8px; border-radius: 50%; width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
@@ -898,7 +831,7 @@ a { color: var(--primary); text-decoration: none; }
border-left: 1px solid var(--border); cursor: pointer; border-left: 1px solid var(--border); cursor: pointer;
transition: background var(--transition); transition: background var(--transition);
} }
.week-day-header:hover { background: var(--day-hover-color); } .week-day-header:hover { background: var(--bg-hover); }
.week-day-header .day-name { .week-day-header .day-name {
font-size: 11px; font-weight: 600; text-transform: uppercase; font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: .5px; color: var(--text-2); letter-spacing: .5px; color: var(--text-2);
@@ -1050,7 +983,7 @@ a { color: var(--primary); text-decoration: none; }
border-radius: 4px; border-radius: 4px;
min-height: 36px; min-height: 36px;
} }
.qtr-cell:hover { background: var(--day-hover-color); } .qtr-cell:hover { background: var(--bg-hover); }
.qtr-cell.today .qtr-day-num { .qtr-cell.today .qtr-day-num {
background: var(--today-color, var(--primary)); background: var(--today-color, var(--primary));
color: #fff; color: #fff;
@@ -1113,7 +1046,7 @@ a { color: var(--primary); text-decoration: none; }
cursor: pointer; transition: background var(--transition); cursor: pointer; transition: background var(--transition);
margin-left: 54px; margin-bottom: 4px; margin-left: 54px; margin-bottom: 4px;
} }
.agenda-event:hover { background: var(--day-hover-color); } .agenda-event:hover { background: var(--bg-hover); }
.agenda-event.past { opacity: .45; } .agenda-event.past { opacity: .45; }
.agenda-ev-color { width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; } .agenda-ev-color { width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; }
.agenda-ev-info { flex: 1; } .agenda-ev-info { flex: 1; }
@@ -1165,7 +1098,7 @@ a { color: var(--primary); text-decoration: none; }
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
transition: background var(--transition), color var(--transition); transition: background var(--transition), color var(--transition);
} }
.rec-day-btn:hover { background: var(--hover-highlight); } .rec-day-btn:hover { background: var(--bg-hover); }
.rec-day-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); } .rec-day-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }
/* ── Day Context Menu ──────────────────────────────────── */ /* ── Day Context Menu ──────────────────────────────────── */
@@ -1178,7 +1111,7 @@ a { color: var(--primary); text-decoration: none; }
.ctx-item { .ctx-item {
padding: 8px 16px; font-size: 13px; color: var(--text-1); cursor: pointer; padding: 8px 16px; font-size: 13px; color: var(--text-1); cursor: pointer;
} }
.ctx-item:hover { background: var(--hover-highlight); } .ctx-item:hover { background: var(--bg-hover); }
/* ── Event Popup ────────────────────────────────────────── /* ── Event Popup ──────────────────────────────────────────
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben. Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
@@ -1302,7 +1235,7 @@ a { color: var(--primary); text-decoration: none; }
border-radius: 10px; border-radius: 10px;
transition: background var(--transition); transition: background var(--transition);
} }
.popup-copy-item:hover { background: var(--hover-highlight); } .popup-copy-item:hover { background: var(--bg-hover); }
.popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.popup-copy-edit-toggle { .popup-copy-edit-toggle {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
@@ -1317,10 +1250,6 @@ a { color: var(--primary); text-decoration: none; }
/* ── Settings Page ──────────────────────────────────────── */ /* ── Settings Page ──────────────────────────────────────── */
#modal-settings.modal-overlay { #modal-settings.modal-overlay {
align-items: stretch; justify-content: stretch; padding: 0; background: var(--bg-app); align-items: stretch; justify-content: stretch; padding: 0; background: var(--bg-app);
/* Full-screen opaque "page". Sit BELOW real modals (z-index 500) so dialogs
opened from inside settings — share, add-account, color picker — appear on
top instead of behind this page (that's why the Share button "did nothing"). */
z-index: 400;
} }
.settings-page-card { .settings-page-card {
width: 100%; height: 100%; width: 100%; height: 100%;
@@ -1331,7 +1260,6 @@ a { color: var(--primary); text-decoration: none; }
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 14px 20px; border-bottom: 1px solid var(--border); padding: 14px 20px; border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
background: var(--bg-topbar);
} }
.settings-page-header h3 { font-size: 16px; font-weight: 600; color: var(--text-1); margin: 0; } .settings-page-header h3 { font-size: 16px; font-weight: 600; color: var(--text-1); margin: 0; }
.settings-page-body { .settings-page-body {
@@ -1342,7 +1270,6 @@ a { color: var(--primary); text-decoration: none; }
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
padding: 12px 8px; padding: 12px 8px;
display: flex; flex-direction: column; gap: 2px; display: flex; flex-direction: column; gap: 2px;
background: var(--bg-sidebar);
} }
.settings-nav-btn { .settings-nav-btn {
display: block; width: 100%; text-align: left; display: block; width: 100%; text-align: left;
@@ -1351,18 +1278,13 @@ a { color: var(--primary); text-decoration: none; }
background: none; border: none; cursor: pointer; background: none; border: none; cursor: pointer;
transition: background .15s, color .15s; transition: background .15s, color .15s;
} }
.settings-nav-btn:hover { background: var(--hover-highlight); color: var(--text-1); } .settings-nav-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.settings-nav-btn.active { background: var(--primary-dim); color: var(--primary); font-weight: 600; } .settings-nav-btn.active { background: var(--primary-dim); color: var(--primary); font-weight: 600; }
.settings-panels { .settings-panels {
flex: 1; overflow-y: auto; padding: 24px 28px; flex: 1; overflow-y: auto; padding: 24px 28px;
} }
.settings-panel { display: none; } .settings-panel { display: none; }
/* Constrain content to a comfortable reading column so fields don't stretch .settings-panel.active { display: block; }
edge-to-edge on wide screens. */
.settings-panel.active { display: block; max-width: 680px; }
/* …except the Kalender panel, whose wide multi-column table needs the full
width (otherwise it's squeezed into a horizontal scroll). */
#settings-panel-accounts { max-width: none; }
/* Panel typography */ /* Panel typography */
.panel-title { .panel-title {
@@ -1389,7 +1311,7 @@ a { color: var(--primary); text-decoration: none; }
transition: background var(--transition), color var(--transition); transition: background var(--transition), color var(--transition);
} }
.contrast-btn:last-child { border-right: none; } .contrast-btn:last-child { border-right: none; }
.contrast-btn:hover { background: var(--hover-highlight); color: var(--text-1); } .contrast-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.contrast-btn.active { background: var(--primary); color: #fff; } .contrast-btn.active { background: var(--primary); color: #fff; }
.contrast-btn span { font-size: 18px; font-weight: 700; line-height: 1; } .contrast-btn span { font-size: 18px; font-weight: 700; line-height: 1; }
.contrast-btn.active span { color: #fff !important; } .contrast-btn.active span { color: #fff !important; }
@@ -1405,131 +1327,6 @@ a { color: var(--primary); text-decoration: none; }
} }
.contrast-btn.active .hour-preview { color: #fff; } .contrast-btn.active .hour-preview { color: #fff; }
/* ── Settings sync table (Sync-Icon | Name | Wert) ─────────── */
.sync-global-row {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 12px 14px; margin-bottom: 12px;
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px;
}
.sync-global-text { display: flex; flex-direction: column; gap: 2px; }
.sync-global-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.sync-global-row .panel-desc { margin: 0; }
.settings-table { display: flex; flex-direction: column; }
.theme-io-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.theme-io-help {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 50%;
background: var(--bg-hover); color: var(--text-2);
font-size: 12px; font-weight: 700; text-decoration: none;
}
.theme-io-help:hover { background: var(--hover-highlight); color: var(--text-1); }
/* Custom instance logo (replaces the glyph+text). Hard-capped so it can only
scale DOWN and never pushes the topbar/auth layout. */
.topbar-logo-img { max-height: 40px; max-width: 150px; width: auto; height: auto; object-fit: contain; display: block; }
.auth-logo-img { max-height: 56px; max-width: 220px; width: auto; height: auto; object-fit: contain; display: block; }
/* Admin panel: default-theme editor + branding */
.admin-theme-grid { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.admin-theme-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
.admin-theme-name { font-size: 13px; color: var(--text-2); }
.admin-theme-actions { display: flex; gap: 8px; margin-top: 14px; }
.admin-brand-row { display: flex; align-items: center; gap: 12px; margin-top: 12px; flex-wrap: wrap; }
.admin-brand-label { width: 70px; font-size: 13px; color: var(--text-2); flex-shrink: 0; }
.admin-brand-preview {
width: 48px; height: 48px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg-hover); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); overflow: hidden;
}
/* Logo preview mirrors its real topbar footprint (max 150×40). */
.admin-brand-preview-logo { width: 150px; height: 40px; }
.admin-brand-preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
.admin-brand-controls { display: flex; flex-direction: column; gap: 4px; }
.admin-brand-btns { display: flex; gap: 8px; }
.admin-brand-dims { font-size: 11px; color: var(--text-3); }
.settings-table-section {
font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
color: var(--text-3); margin: 18px 0 6px; padding: 0 2px;
}
.settings-table-section:first-child { margin-top: 0; }
.settings-row {
display: grid; grid-template-columns: 40px 1fr minmax(140px, auto);
align-items: center; gap: 12px;
padding: 10px 4px; border-bottom: 1px solid var(--border-light);
}
.settings-row:last-child { border-bottom: none; }
.settings-row-name { font-size: 14px; color: var(--text-1); }
.settings-row-value { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.settings-row-value select {
background: var(--bg-app);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
color: var(--text-1);
outline: none;
cursor: pointer;
color-scheme: dark;
min-width: 130px; max-width: 100%;
transition: border-color var(--transition);
}
.settings-row-value select:hover:not(:focus) { border-color: var(--text-3); }
.settings-row-value select:focus { border-color: var(--primary); }
/* Sync toggle switch — used per row and (larger) globally */
.sync-toggle {
position: relative; flex: 0 0 auto;
width: 34px; height: 20px; padding: 0;
border: none; border-radius: 999px; cursor: pointer;
background: var(--bg-active); transition: background var(--transition);
}
.sync-toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px; border-radius: 50%;
background: #fff; transition: transform var(--transition);
}
.sync-toggle.on { background: var(--primary); }
.sync-toggle.on::after { transform: translateX(14px); }
.sync-toggle-lg { width: 44px; height: 26px; }
.sync-toggle-lg::after { width: 22px; height: 22px; }
.sync-toggle-lg.on::after { transform: translateX(18px); }
.settings-row .sync-toggle { justify-self: center; }
/* Per-row sync toggle icon: ON = primary + pill, OFF = muted + slashed icon */
.sync-icon {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 28px; padding: 0;
border: none; border-radius: 8px; cursor: pointer;
background: transparent; color: var(--text-3);
transition: color var(--transition), background var(--transition);
justify-self: center;
}
.sync-icon:hover { background: var(--hover-highlight); color: var(--text-2); }
.sync-icon.on { color: var(--primary); background: var(--primary-dim); }
.sync-icon .si-on, .sync-icon .si-off { align-items: center; }
.sync-icon .si-on { display: none; }
.sync-icon .si-off { display: inline-flex; }
.sync-icon.on .si-off { display: none; }
.sync-icon.on .si-on { display: inline-flex; }
/* Per-row colour control: swatch + hex + reset, right-aligned */
.settings-color-ctl { display: flex; align-items: center; gap: 12px; }
.settings-color-ctl .ev-color-hex { width: 92px; }
.settings-color-ctl .ev-color-preview { margin-left: 2px; }
.settings-color-ctl .ev-color-reset {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; padding: 0; color: var(--text-3);
}
/* Inline share-icon picker inside a value cell — one even row */
.settings-icon-ctl { display: flex; flex-wrap: nowrap; gap: 6px; justify-content: flex-end; }
@media (max-width: 640px) {
.settings-row { grid-template-columns: 34px 1fr; grid-auto-rows: auto; }
.settings-row-value { grid-column: 1 / -1; justify-content: flex-start; padding-left: 46px; }
}
/* ── Settings (legacy) ──────────────────────────────────── */ /* ── Settings (legacy) ──────────────────────────────────── */
.settings-section { margin-bottom: 28px; } .settings-section { margin-bottom: 28px; }
.settings-section h4 { font-size: 14px; font-weight: 600; color: var(--text-1); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } .settings-section h4 { font-size: 14px; font-weight: 600; color: var(--text-1); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
@@ -1656,18 +1453,7 @@ a { color: var(--primary); text-decoration: none; }
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light);
} }
.cal-manage-table td { padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border-light); vertical-align: middle; } .cal-manage-table td { padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border-light); vertical-align: middle; }
/* Fixed layout + narrow drags must clip, not overlap the next column (Excel-like). */
.cal-manage-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Full-width detail/section rows (account headers, CalDAV boxes, empty state) wrap normally. */
.cal-manage-table td[colspan] { overflow: visible; white-space: normal; }
.cal-manage-table tbody tr:last-child td { border-bottom: none; } .cal-manage-table tbody tr:last-child td { border-bottom: none; }
/* Drag-resizable columns: a grab handle on each header's right edge. */
.cal-manage-table th { position: relative; }
.cal-manage-table th .col-resizer {
position: absolute; top: 0; right: 0; width: 8px; height: 100%;
cursor: col-resize; user-select: none; touch-action: none;
}
.cal-manage-table th .col-resizer:hover { background: var(--border); }
.ct-acc-row td { padding-top: 14px; background: none; } .ct-acc-row td { padding-top: 14px; background: none; }
.ct-acc-row td:first-child { font-size: 13px; } .ct-acc-row td:first-child { font-size: 13px; }
.ct-cal-row .ct-indent { padding-left: 16px; } .ct-cal-row .ct-indent { padding-left: 16px; }
@@ -1682,28 +1468,6 @@ a { color: var(--primary); text-decoration: none; }
.ct-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; flex-shrink: 0; } .ct-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; flex-shrink: 0; }
.ct-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; } .ct-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; }
.ct-toggle { cursor: pointer; } .ct-toggle { cursor: pointer; }
.ct-dav-toggle.on { color: var(--primary); }
.ct-dav-row td { padding-top: 4px; padding-bottom: 12px; }
.ct-dav-box { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ct-dav-label { font-size: 12px; color: var(--text-3); }
.ct-dav-url {
flex: 1; min-width: 220px; font-size: 12px; padding: 5px 8px;
border: 1px solid var(--border); border-radius: 6px;
background: var(--surface-2); color: var(--text-1);
}
.ct-dav-hint { font-size: 11px; color: var(--text-3); margin-top: 6px; max-width: 640px; }
/* Row (not the column inherited from .form-group) so the "Erstellen" button
sits inline to the right of the name field instead of centred below it. */
.app-pw-create { display: flex; flex-direction: row; gap: 8px; align-items: center; }
.app-pw-create input { flex: 1; }
/* Consistent placement for a section's action button: right-aligned, matching
the sticky header save and the app-password create row. */
.settings-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
.app-pw-new { margin: 8px 0; }
.app-pw-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-top: 1px solid var(--border); }
.app-pw-name { font-weight: 500; }
.app-pw-meta { font-size: 11px; color: var(--text-3); margin-left: auto; }
.ct-eye, .ct-bell { opacity: .45; transition: opacity .15s; } .ct-eye, .ct-bell { opacity: .45; transition: opacity .15s; }
.ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; } .ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; }
.ct-eye:hover, .ct-bell:hover { opacity: 1; } .ct-eye:hover, .ct-bell:hover { opacity: 1; }
@@ -1797,7 +1561,7 @@ a { color: var(--primary); text-decoration: none; }
padding: 2px 6px; border-radius: var(--radius-sm); padding: 2px 6px; border-radius: var(--radius-sm);
transition: background var(--transition), color var(--transition); transition: background var(--transition), color var(--transition);
} }
.dtp-nav-btn:hover { background: var(--hover-highlight); color: var(--text-1); } .dtp-nav-btn:hover { background: var(--bg-hover); color: var(--text-1); }
/* Day-of-week headers */ /* Day-of-week headers */
.dtp-grid { .dtp-grid {
display: grid; grid-template-columns: repeat(7, 1fr); display: grid; grid-template-columns: repeat(7, 1fr);
@@ -1814,9 +1578,9 @@ a { color: var(--primary); text-decoration: none; }
font-size: 13px; font-weight: 500; color: var(--text-1); font-size: 13px; font-weight: 500; color: var(--text-1);
cursor: pointer; transition: background var(--transition); cursor: pointer; transition: background var(--transition);
} }
.dtp-day:hover { background: var(--day-hover-color); } .dtp-day:hover { background: var(--bg-hover); }
.dtp-day.other { color: var(--text-3); } .dtp-day.other { color: var(--text-3); }
.dtp-day.other:hover { background: var(--day-hover-color); } .dtp-day.other:hover { background: var(--bg-hover); }
.dtp-day.today { color: var(--primary); font-weight: 700; } .dtp-day.today { color: var(--primary); font-weight: 700; }
.dtp-day.selected { .dtp-day.selected {
background: var(--primary) !important; background: var(--primary) !important;
@@ -2228,57 +1992,12 @@ a { color: var(--primary); text-decoration: none; }
border-radius: 10px; border-radius: 10px;
} }
.share-user-item { .share-user-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; padding: 10px 14px;
cursor: pointer; cursor: pointer;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.share-user-item:last-child { border-bottom: none; } .share-user-item:last-child { border-bottom: none; }
.share-user-item:hover { background: var(--bg-surface); } .share-user-item:hover { background: var(--bg-surface); }
.share-user-item input[type=checkbox] {
flex-shrink: 0; width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer;
}
/* Add-user row: match the app's dark inputs instead of raw white browser
controls, with a subtle focus ring for a modern feel. */
#share-user-search,
#share-permission {
background: var(--bg-app);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
color: var(--text-1);
font-size: 14px;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
#share-user-search::placeholder { color: var(--text-3); }
#share-permission { cursor: pointer; }
#share-user-search:focus,
#share-permission:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.15);
}
/* "Shared with me" section header row inside the calendar-management table */
.ct-section-row td {
padding-top: 14px; font-size: 12px; font-weight: 600;
color: var(--text-3); text-transform: uppercase; letter-spacing: .04em;
}
/* Higher specificity than the global ".form-group label" (uppercase / spaced /
12px) so a checkbox label reads as normal inline text next to its box, not a
stretched uppercase column. */
.form-group label.checkbox-row,
.checkbox-row {
display: flex; align-items: center; justify-content: flex-start; gap: 8px;
cursor: pointer; text-transform: none; letter-spacing: normal;
font-size: 14px; font-weight: 400; color: var(--text-1);
}
/* Undo the global ".form-group input { width:100%; padding; border }" so the
checkbox stays a small native box instead of a full-width field that shoves
the label text into a narrow, wrapping column. */
.checkbox-row input[type=checkbox] {
flex: none; width: 16px; height: 16px; margin: 0; padding: 0;
border: 0; border-radius: 0; background: none; accent-color: var(--primary);
}
/* .popup-creator styling moved into the .popup-row / #popup-creator rules above. */ /* .popup-creator styling moved into the .popup-row / #popup-creator rules above. */
/* ── Groups ─────────────────────────────────────────────────── */ /* ── Groups ─────────────────────────────────────────────────── */
@@ -2410,8 +2129,6 @@ a { color: var(--primary); text-decoration: none; }
/* Group emoji + icon picker */ /* Group emoji + icon picker */
.group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; } .group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; }
.cal-shared-flag { flex: 0 0 auto; font-size: 12px; opacity: .8; } .cal-shared-flag { flex: 0 0 auto; font-size: 12px; opacity: .8; }
/* Read-only (shared with me) indicator: a struck-through pencil. */
.cal-readonly-flag { color: var(--icon-inactive-color); opacity: .7; margin-left: 2px; }
.group-icon-picker { display: flex; flex-wrap: wrap; gap: 6px; } .group-icon-picker { display: flex; flex-wrap: wrap; gap: 6px; }
.group-icon-opt { .group-icon-opt {
width: 38px; height: 38px; width: 38px; height: 38px;
+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#2ea05a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/> <rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/> <line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/> <line x1="8" y1="2" x2="8" y2="6"/>

Before

Width:  |  Height:  |  Size: 332 B

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#16713d"/> <rect width="512" height="512" fill="#4285f4"/>
<g fill="none" stroke="#ffffff" stroke-width="23" stroke-linecap="round" stroke-linejoin="round"> <g fill="none" stroke="#ffffff" stroke-width="23" stroke-linecap="round" stroke-linejoin="round">
<rect x="102" y="154" width="307" height="266" rx="26"/> <rect x="102" y="154" width="307" height="266" rx="26"/>
<line x1="102" y1="234" x2="409" y2="234"/> <line x1="102" y1="234" x2="409" y2="234"/>

Before

Width:  |  Height:  |  Size: 432 B

After

Width:  |  Height:  |  Size: 432 B

+125 -172
View File
@@ -7,8 +7,7 @@
<title>Calendarr</title> <title>Calendarr</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#16713d" /> <meta name="theme-color" content="#4285f4" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Calendarr" /> <meta name="apple-mobile-web-app-title" content="Calendarr" />
@@ -77,16 +76,9 @@
<label class="toggle-label" style="margin-bottom:14px"> <label class="toggle-label" style="margin-bottom:14px">
<input type="checkbox" id="login-remember" /> Angemeldet bleiben <input type="checkbox" id="login-remember" /> Angemeldet bleiben
</label> </label>
<div id="login-hint" class="form-hint hidden"></div>
<div id="login-error" class="form-error hidden"></div> <div id="login-error" class="form-error hidden"></div>
<button type="submit" class="btn btn-primary btn-full">Anmelden</button> <button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form> </form>
<!-- Single sign-on: filled in by renderSsoButtons() when a provider is
configured, otherwise stays hidden and the login screen is unchanged. -->
<div id="sso-block" class="hidden">
<div class="auth-divider">oder</div>
<div id="sso-buttons"></div>
</div>
</div> </div>
<button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices</button> <button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices</button>
</div> </div>
@@ -166,19 +158,10 @@
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar-inner"> <div class="sidebar-inner">
<div class="create-split add-cal-dropdown-wrap"> <button class="btn btn-fab" id="btn-create-event">
<button class="btn btn-fab create-main" id="btn-create-event"> <svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg> <span data-i18n="btn_create">Erstellen</span>
<span data-i18n="btn_create">Erstellen</span> </button>
</button>
<button class="btn btn-fab create-caret" id="btn-create-menu" data-i18n-title="birthday_new" title="Neuer Geburtstag" aria-haspopup="menu" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M7 10l5 5 5-5z"/></svg>
</button>
<div class="add-cal-dropdown create-menu hidden" id="create-menu" role="menu">
<button data-action="event" data-i18n="create_event_option">Neuer Termin</button>
<button data-action="birthday" data-i18n="birthday_new">Neuer Geburtstag</button>
</div>
</div>
<!-- Mini Calendar --> <!-- Mini Calendar -->
<div class="mini-cal" id="mini-cal"> <div class="mini-cal" id="mini-cal">
@@ -214,7 +197,6 @@
</button> </button>
<div class="add-cal-dropdown hidden" id="add-cal-dropdown"> <div class="add-cal-dropdown hidden" id="add-cal-dropdown">
<button data-action="local">Lokaler Kalender</button> <button data-action="local">Lokaler Kalender</button>
<button data-action="birthday" data-i18n="birthday_calendar_type">Geburtstagskalender</button>
<button data-action="caldav">CalDAV-Konto</button> <button data-action="caldav">CalDAV-Konto</button>
<button data-action="ical">iCal-URL abonnieren</button> <button data-action="ical">iCal-URL abonnieren</button>
<button data-action="google">Google Kalender</button> <button data-action="google">Google Kalender</button>
@@ -495,24 +477,6 @@
</div> </div>
</div> </div>
<!-- Generic Confirm Dialog (styled replacement for window.confirm) -->
<div id="modal-confirm" class="modal-overlay hidden">
<div class="modal-card" style="max-width:400px">
<div class="modal-header">
<h3 id="confirm-title">Bestätigen</h3>
<button class="icon-btn modal-close" data-modal="modal-confirm">&times;</button>
</div>
<div class="modal-body">
<p id="confirm-text"></p>
</div>
<div class="modal-footer">
<div style="flex:1"></div>
<button class="btn btn-ghost" id="confirm-cancel" data-modal="modal-confirm" data-i18n="cancel">Abbrechen</button>
<button class="btn btn-danger" id="confirm-ok" data-i18n="delete">Löschen</button>
</div>
</div>
</div>
<!-- Event Detail Popup --> <!-- Event Detail Popup -->
<div id="popup-event" class="event-popup hidden"> <div id="popup-event" class="event-popup hidden">
<div class="popup-header"> <div class="popup-header">
@@ -625,44 +589,6 @@
</div> </div>
</div> </div>
<!-- Birthday modal -->
<div id="modal-birthday" class="modal-overlay hidden">
<div class="modal-card" style="max-width:400px">
<div class="modal-header">
<h3 data-i18n="birthday_modal_title">Neuen Geburtstag hinzufügen</h3>
<button class="icon-btn modal-close" data-modal="modal-birthday">&times;</button>
</div>
<div class="modal-body">
<div id="birthday-no-cal" class="hidden">
<div class="form-hint" data-i18n="birthday_activate_hint">Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.</div>
<button class="btn btn-primary btn-sm" id="birthday-activate" data-i18n="birthday_activate">Geburtstagskalender aktivieren</button>
</div>
<div id="birthday-form">
<div class="form-group">
<label data-i18n="birthday_person">Name</label>
<input type="text" id="birthday-name" data-i18n-ph="birthday_person_ph" placeholder="Name der Person" />
</div>
<div class="form-group">
<label data-i18n="birthday_date">Geburtstag</label>
<div class="birthday-date-row">
<select id="birthday-day"></select>
<select id="birthday-month"></select>
<input type="number" id="birthday-year" min="1900" max="2100" data-i18n-ph="birthday_year_ph" placeholder="Jahr" />
</div>
<label class="checkbox-row" style="margin-top:10px;display:flex;align-items:center;gap:8px;text-transform:none;letter-spacing:normal;font-weight:400;cursor:pointer">
<input type="checkbox" id="birthday-year-unknown" style="width:16px;height:16px;flex:none;margin:0" />
<span data-i18n="birthday_year_unknown">Jahr unbekannt</span>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-modal="modal-birthday" data-i18n="cancel">Abbrechen</button>
<button class="btn btn-primary" id="birthday-save" data-i18n="save">Speichern</button>
</div>
</div>
</div>
<!-- iCal Subscription Modal --> <!-- iCal Subscription Modal -->
<div id="modal-ical-sub" class="modal-overlay hidden"> <div id="modal-ical-sub" class="modal-overlay hidden">
<div class="modal-card" style="max-width:480px"> <div class="modal-card" style="max-width:480px">
@@ -767,7 +693,7 @@
<button class="settings-nav-btn active" data-panel="profile" data-i18n="settings_nav_profile">Profil</button> <button class="settings-nav-btn active" data-panel="profile" data-i18n="settings_nav_profile">Profil</button>
<button class="settings-nav-btn" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button> <button class="settings-nav-btn" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
<button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_calendars">Kalender</button> <button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_calendars">Kalender</button>
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_admin">Admin</button> <button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
</nav> </nav>
<div class="settings-panels"> <div class="settings-panels">
@@ -788,31 +714,7 @@
<label>E-Mail</label> <label>E-Mail</label>
<input type="email" id="cfg-email" placeholder="Keine E-Mail hinterlegt" /> <input type="email" id="cfg-email" placeholder="Keine E-Mail hinterlegt" />
</div> </div>
<button class="btn btn-primary btn-sm" id="cfg-profile-save" data-i18n="save">Speichern</button>
<h4 class="panel-title" style="margin-top:24px" data-i18n="app_pw_title">App-Passwörter (CalDAV)</h4>
<p class="panel-desc" data-i18n="app_pw_desc">Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.</p>
<div class="form-group app-pw-create">
<input type="text" id="app-pw-label" data-i18n-placeholder="app_pw_label_ph" placeholder="Name (z.B. iPhone)" maxlength="100" />
<button class="btn btn-primary btn-sm" id="app-pw-create-btn" data-i18n="app_pw_create">Erstellen</button>
</div>
<div id="app-pw-new" class="app-pw-new hidden">
<label data-i18n="app_pw_new_label">Neues App-Passwort (nur jetzt sichtbar):</label>
<div class="totp-secret-row">
<code id="app-pw-new-value"></code>
<button class="btn btn-ghost btn-sm" id="app-pw-copy" title="Kopieren">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</button>
</div>
</div>
<div id="app-pw-list"></div>
<!-- Single sign-on: hidden unless the server has a provider or the
account already holds a linked identity. -->
<div id="sso-section" class="hidden">
<h4 class="panel-title" style="margin-top:24px" data-i18n="sso_link_title">Single Sign-On</h4>
<p class="panel-desc" data-i18n="sso_link_desc">Verknüpfte Konten, mit denen du dich ohne Passwort anmelden kannst.</p>
<div id="sso-identity-list"></div>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_privacy">Privatsphäre</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_privacy">Privatsphäre</h4>
<p class="panel-desc" data-i18n="settings_private_visibility_desc">Wie private Termine für andere Gruppenmitglieder erscheinen</p> <p class="panel-desc" data-i18n="settings_private_visibility_desc">Wie private Termine für andere Gruppenmitglieder erscheinen</p>
@@ -823,12 +725,15 @@
<option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option> <option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option>
</select> </select>
</div> </div>
<div class="form-group">
<label class="checkbox-row"> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_default_duration">Standard-Termindauer</h4>
<input type="checkbox" id="cfg-directory-hidden" /> <div class="contrast-selector" id="cfg-event-duration">
<span data-i18n="settings_directory_hidden">Profil verbergen (nicht in Teilen-/Gruppen-Auswahl anzeigen)</span> <button class="contrast-btn" data-val="15">15 min</button>
</label> <button class="contrast-btn" data-val="30">30 min</button>
<p class="panel-desc" data-i18n="settings_directory_hidden_desc">Andere Nutzer können dich dann nicht auswählen, um Kalender zu teilen oder dich zu Gruppen hinzuzufügen. In der Admin-Benutzerverwaltung bleibst du sichtbar.</p> <button class="contrast-btn" data-val="45">45 min</button>
<button class="contrast-btn" data-val="60">1 h</button>
<button class="contrast-btn" data-val="90">1,5 h</button>
<button class="contrast-btn" data-val="120">2 h</button>
</div> </div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendars">Geteilter Kalender</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendars">Geteilter Kalender</h4>
@@ -839,21 +744,115 @@
</div> </div>
</div> </div>
<!-- Darstellung: einheitliche Sync-Tabelle (Sync-Icon | Name | Wert) --> <!-- Darstellung: Kalenderansicht, Sprache, Stundenhöhe, Farben -->
<div class="settings-panel" id="settings-panel-general"> <div class="settings-panel" id="settings-panel-general">
<div class="sync-global-row">
<div class="sync-global-text"> <h4 class="panel-title" data-i18n="settings_calendar_view">Kalenderansicht</h4>
<span class="sync-global-title" data-i18n="settings_sync_all">Alle synchronisieren</span> <div class="form-group">
<span class="panel-desc" data-i18n="settings_sync_all_desc">Diese Einstellungen zwischen deinen Geräten teilen</span> <label data-i18n="settings_default_view">Standardansicht</label>
</div> <select id="cfg-default-view">
<button type="button" class="sync-toggle sync-toggle-lg" id="cfg-sync-all" role="switch" aria-checked="false"></button> <option value="month" data-i18n="view_month">Monat</option>
<option value="week" data-i18n="view_week">Woche</option>
<option value="day" data-i18n="view_day">Tag</option>
<option value="quarter" data-i18n="view_quarter">Quartal</option>
<option value="agenda" data-i18n="view_agenda">Termine</option>
</select>
</div> </div>
<div class="settings-table" id="settings-appearance-table"></div> <div class="form-group">
<div class="theme-io-row"> <label data-i18n="settings_week_start">Erster Wochentag</label>
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-export" data-i18n="theme_export">Theme exportieren</button> <select id="cfg-week-start">
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-import" data-i18n="theme_import">Theme importieren</button> <option value="monday" data-i18n="week_start_monday">Montag</option>
<a class="theme-io-help" id="cfg-theme-help" href="https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md" target="_blank" rel="noopener noreferrer" data-i18n-title="theme_docs_hint" title="Erklärung der Theme-Parameter">?</a> <option value="sunday" data-i18n="week_start_sunday">Sonntag</option>
<input type="file" id="cfg-theme-file" accept=".theme.json,.json,.theme,application/json" hidden /> </select>
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="cfg-dim-past" />
<span data-i18n="settings_dim_past">Vergangene Termine ausgrauen</span>
</label>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_language">Sprache</h4>
<div class="form-group">
<select id="cfg-language">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- &amp; Tagesansicht)</h4>
<p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p>
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">
<button class="contrast-btn" data-val="28"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</span></button>
<button class="contrast-btn" data-val="44"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</span></button>
<button class="contrast-btn" data-val="60"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</span></button>
<button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button>
</div>
<h4 class="panel-title" style="margin-top:24px">Teilen-Symbol</h4>
<p class="panel-desc">Symbol neben deinem geteilten Kalender in der Seitenleiste</p>
<div class="group-icon-picker" id="cfg-share-icon"></div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_colors">Farben</h4>
<div class="form-group">
<label data-i18n="settings_primary_color">Primärfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-primary-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-primary-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_accent_color">Akzentfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-accent-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-accent-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_today_color">Heutige-Tag-Farbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-today-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-today-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_month_divider_color">Monatswechsel-Linie</label>
<div class="ev-color-row">
<input type="text" id="cfg-month-divider-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-month-divider-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_month_label_color">Monatskürzel</label>
<div class="ev-color-row">
<input type="text" id="cfg-month-label-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-month-label-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_text_color">Schriftfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-text-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<div class="ev-color-preview" id="cfg-text-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button type="button" class="btn btn-ghost btn-sm" id="cfg-text-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_line_color">Linienfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-line-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<div class="ev-color-preview" id="cfg-line-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button type="button" class="btn btn-ghost btn-sm" id="cfg-line-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_bg_color">Hintergrundfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-bg-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<div class="ev-color-preview" id="cfg-bg-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button type="button" class="btn btn-ghost btn-sm" id="cfg-bg-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div>
</div> </div>
</div> </div>
@@ -862,19 +861,15 @@
<h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4> <h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4>
<div class="accounts-add-row"> <div class="accounts-add-row">
<button class="btn btn-secondary btn-sm" id="settings-btn-add-local">+ Lokal</button> <button class="btn btn-secondary btn-sm" id="settings-btn-add-local">+ Lokal</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-birthday" data-i18n="birthday_add_btn">+ Geburtstage</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-caldav">+ CalDAV</button> <button class="btn btn-secondary btn-sm" id="settings-btn-add-caldav">+ CalDAV</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ical">+ iCal</button> <button class="btn btn-secondary btn-sm" id="settings-btn-add-ical">+ iCal</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ha">+ Home Assistant</button> <button class="btn btn-secondary btn-sm" id="settings-btn-add-ha">+ Home Assistant</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-google">+ Google</button> <button class="btn btn-secondary btn-sm" id="settings-btn-add-google">+ Google</button>
</div> </div>
<div id="cal-settings-table" style="margin-top:16px;overflow-x:auto"></div> <div id="cal-settings-table" style="margin-top:16px;overflow-x:auto"></div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="birthday_settings_title">Geburtstage</h4>
<div id="birthday-settings"></div>
</div> </div>
<!-- Admin: Benutzerverwaltung + Standard-Theme + Branding --> <!-- Benutzerverwaltung -->
<div class="settings-panel" id="settings-panel-users"> <div class="settings-panel" id="settings-panel-users">
<h4 class="panel-title"><span data-i18n="settings_nav_users">Benutzerverwaltung</span> <span class="badge-admin">Admin</span></h4> <h4 class="panel-title"><span data-i18n="settings_nav_users">Benutzerverwaltung</span> <span class="badge-admin">Admin</span></h4>
<div id="users-list"></div> <div id="users-list"></div>
@@ -893,45 +888,6 @@
</label> </label>
<button class="btn btn-primary" id="new-user-save">Erstellen</button> <button class="btn btn-primary" id="new-user-save">Erstellen</button>
</div> </div>
<!-- Instanz-Standardtheme -->
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_default_theme">Standard-Theme</h4>
<p class="panel-desc" data-i18n="admin_default_theme_desc">Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.</p>
<div id="admin-theme-grid" class="admin-theme-grid"></div>
<div class="admin-theme-actions">
<button class="btn btn-primary btn-sm" id="admin-theme-save" data-i18n="admin_theme_save">Standard speichern</button>
<button class="btn btn-secondary btn-sm" id="admin-theme-import" data-i18n="admin_theme_import">Theme importieren</button>
<button class="btn btn-ghost btn-sm" id="admin-theme-discard" data-i18n="admin_theme_discard">Verwerfen</button>
<input type="file" id="admin-theme-file" accept=".theme.json,.json,.theme,application/json" hidden />
</div>
<!-- Branding: Logo & Favicon -->
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_branding">Branding</h4>
<p class="panel-desc" data-i18n="admin_branding_desc">Eigenes Logo (oben links) und Favicon (Tab-Symbol) für die ganze Instanz. PNG/JPEG/WebP, max. 5 MB.</p>
<div class="admin-brand-row">
<span class="admin-brand-label" data-i18n="admin_logo">Logo</span>
<div class="admin-brand-preview admin-brand-preview-logo" id="admin-logo-preview"></div>
<div class="admin-brand-controls">
<div class="admin-brand-btns">
<button class="btn btn-secondary btn-sm" id="admin-logo-upload" data-i18n="admin_upload">Hochladen</button>
<button class="btn btn-ghost btn-sm" id="admin-logo-remove" data-i18n="admin_remove">Entfernen</button>
</div>
<span class="admin-brand-dims" data-i18n="admin_logo_dims">PNG/JPEG/WebP · Anzeige max. 150 × 40 px</span>
</div>
<input type="file" id="admin-logo-file" accept="image/png,image/jpeg,image/webp" hidden />
</div>
<div class="admin-brand-row">
<span class="admin-brand-label" data-i18n="admin_favicon">Favicon</span>
<div class="admin-brand-preview" id="admin-favicon-preview"></div>
<div class="admin-brand-controls">
<div class="admin-brand-btns">
<button class="btn btn-secondary btn-sm" id="admin-favicon-upload" data-i18n="admin_upload">Hochladen</button>
<button class="btn btn-ghost btn-sm" id="admin-favicon-remove" data-i18n="admin_remove">Entfernen</button>
</div>
<span class="admin-brand-dims" data-i18n="admin_favicon_dims">PNG/JPEG/WebP · 128 × 128 px (quadratisch)</span>
</div>
<input type="file" id="admin-favicon-file" accept="image/png,image/jpeg,image/webp" hidden />
</div>
</div> </div>
</div><!-- settings-panels --> </div><!-- settings-panels -->
@@ -1023,10 +979,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Bestätigungscode eingeben</label> <label>Bestätigungscode eingeben</label>
<!-- data-bwignore/-1p-ignore/-lpignore: this is the *enrolment* field for a <input type="text" id="2fa-verify-code" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
new secret, and a second one-time-code field on the page makes password
managers ambiguous about which one their inline icon belongs to. -->
<input type="text" id="2fa-verify-code" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code" data-bwignore data-1p-ignore data-lpignore="true" />
</div> </div>
<button class="btn btn-primary btn-sm" id="2fa-enable-btn">Aktivieren</button> <button class="btn btn-primary btn-sm" id="2fa-enable-btn">Aktivieren</button>
<button class="btn btn-ghost btn-sm" id="2fa-cancel-btn">Abbrechen</button> <button class="btn btn-ghost btn-sm" id="2fa-cancel-btn">Abbrechen</button>
+1 -14
View File
@@ -1,11 +1,6 @@
import { t } from './i18n.js'; import { t } from './i18n.js';
const BASE = '/api'; const BASE = '/api';
// Paths where a 401 is a normal answer rather than an expired session, so the
// auto-logout reload must not fire (it would loop on a failed SSO handoff).
const NO_RELOAD_ON_401 = ['/auth/login', '/auth/oidc/complete', '/auth/oidc/exchange',
'/auth/oidc/link-pending'];
async function request(method, path, body = null, formEncoded = false) { async function request(method, path, body = null, formEncoded = false) {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const headers = {}; const headers = {};
@@ -25,7 +20,7 @@ async function request(method, path, body = null, formEncoded = false) {
const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr }); const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr });
if (res.status === 401 && !NO_RELOAD_ON_401.some(p => path.startsWith(p))) { if (res.status === 401 && !path.startsWith('/auth/login')) {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
window.location.reload(); window.location.reload();
@@ -113,12 +108,4 @@ export const api = {
setupRequired: () => request('GET', '/auth/setup-required'), setupRequired: () => request('GET', '/auth/setup-required'),
setup: (data) => request('POST', '/auth/setup', data), setup: (data) => request('POST', '/auth/setup', data),
// Single sign-on. oidcComplete() trades the one-time HttpOnly handoff cookie
// set by the OIDC callback for the access token.
oidcProviders: () => request('GET', '/auth/oidc/providers'),
oidcComplete: () => request('POST', '/auth/oidc/complete', {}),
oidcIdentities: () => request('GET', '/auth/oidc/identities'),
oidcLinkPending: () => request('POST', '/auth/oidc/link-pending', {}),
oidcUnlink: (id) => request('DELETE', `/auth/oidc/identities/${id}`),
}; };
+6 -165
View File
@@ -1,116 +1,9 @@
import { api } from './api.js'; import { api } from './api.js';
import { initCalendar, showToast, openProfileModal } from './calendar.js'; import { initCalendar, showToast, openProfileModal } from './calendar.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { loadInstance } from './instance.js';
// ── Single Sign-On ────────────────────────────────────────
// Error slug from a failed SSO round-trip, shown once the login screen is up.
let ssoErrorSlug = null;
// True when an SSO identity is parked server-side, waiting to be linked to
// whichever account the user signs into next.
let ssoLinkPending = false;
/**
* Handle the return leg of an OIDC login.
*
* The callback redirected us to /?sso=1 with the access token in a one-time
* HttpOnly cookie; trade it for the token here. Returns true when the user is
* now logged in. Must run before the setup/token checks in boot().
*/
async function consumeSsoHandoff() {
const params = new URLSearchParams(window.location.search);
const ok = params.has('sso');
const err = params.get('sso_error');
const link = params.has('sso_link');
if (!ok && !err && !link) return false; // normal load — zero cost
// Strip the marker before awaiting anything: a reload mid-flight must not
// try to redeem a cookie that has already been spent.
window.history.replaceState({}, '', window.location.pathname + window.location.hash);
if (link) { ssoLinkPending = true; return false; }
if (err) { ssoErrorSlug = err; return false; }
try {
const res = await api.oidcComplete();
if (!res || !res.access_token) { ssoErrorSlug = 'generic'; return false; }
localStorage.setItem('token', res.access_token);
localStorage.setItem('user', JSON.stringify(res.user));
return true;
} catch (e) {
ssoErrorSlug = 'generic';
return false;
}
}
/** Render one button per configured provider on the login screen. */
async function renderSsoButtons() {
const block = document.getElementById('sso-block');
const list = document.getElementById('sso-buttons');
if (!block || !list) return;
if (ssoLinkPending) {
const hintEl = document.getElementById('login-hint');
if (hintEl) {
hintEl.textContent = t('sso_link_prompt');
hintEl.classList.remove('hidden');
}
}
if (ssoErrorSlug) {
const errEl = document.getElementById('login-error');
if (errEl) {
// Backend slugs come in both shapes: policy refusals are prefixed
// ("oidc_account_not_linked"), flow errors are not ("state_mismatch").
const bare = ssoErrorSlug.replace(/^oidc_/, '');
const msg = [`sso_err_${ssoErrorSlug}`, `sso_err_${bare}`]
.map(k => [k, t(k)])
.find(([k, v]) => v !== k);
// An unmapped slug still has to be diagnosable — showing only the
// generic text hides the one piece of information that identifies the
// fault.
errEl.textContent = msg ? msg[1] : `${t('sso_err_generic')} (${ssoErrorSlug})`;
errEl.classList.remove('hidden');
}
ssoErrorSlug = null;
}
let cfg;
try {
cfg = await api.oidcProviders();
} catch (e) {
return; // SSO unreachable → password only
}
if (!cfg || !cfg.enabled || !cfg.providers.length) return;
list.innerHTML = '';
for (const p of cfg.providers) {
const a = document.createElement('a');
a.className = 'btn btn-secondary btn-full sso-btn';
// A real link, not fetch(): /start answers 302 to the identity provider and
// needs a top-level navigation to follow it and to set the flow cookie.
a.href = p.start_url;
a.textContent = `${p.icon ? p.icon + ' ' : ''}${t('sso_login_with', { name: p.name })}`;
list.appendChild(a);
}
const divider = block.querySelector('.auth-divider');
if (divider) divider.textContent = t('sso_or');
block.classList.remove('hidden');
}
// ── Bootstrap ───────────────────────────────────────────── // ── Bootstrap ─────────────────────────────────────────────
async function boot() { async function boot() {
// Apply instance branding (logo/favicon/default theme) ASAP so the login and
// setup screens are already branded. Public endpoint — no token needed.
loadInstance();
// Returning from an SSO login? That has to be settled before anything else,
// because we arrive unauthenticated with a one-time cookie in hand.
if (await consumeSsoHandoff()) {
await launchApp();
return;
}
// Check if setup is required // Check if setup is required
let setupRequired = false; let setupRequired = false;
try { try {
@@ -130,28 +23,18 @@ async function boot() {
// Check if already logged in // Check if already logged in
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token) {
let authed = false;
try { try {
await api.get('/auth/me'); // validate the TOKEN only await api.get('/auth/me'); // validate token
authed = true;
} catch (_) {
// The token is genuinely invalid/expired — clear it and show login.
localStorage.removeItem('token');
localStorage.removeItem('user');
}
if (authed) {
// Token is valid → stay logged in. launchApp() runs OUTSIDE the auth
// try/catch on purpose: a later data/render error inside app init must
// never bounce a validly-authenticated user back to the login screen
// (that was the "logged out on every reload" bug).
await launchApp(); await launchApp();
return; return;
} catch (_) {
localStorage.removeItem('token');
localStorage.removeItem('user');
} }
} }
showScreen('login'); showScreen('login');
bindLoginForm(); bindLoginForm();
renderSsoButtons();
} }
function showScreen(name) { function showScreen(name) {
@@ -167,13 +50,6 @@ function showScreen(name) {
async function launchApp() { async function launchApp() {
showScreen('app'); showScreen('app');
// Confirm a linking that happened during this login.
const linkedName = sessionStorage.getItem('ssoLinkedName');
if (linkedName !== null) {
sessionStorage.removeItem('ssoLinkedName');
showToast(t('sso_linked_toast', { name: linkedName }));
}
// Set user avatar initials // Set user avatar initials
const user = JSON.parse(localStorage.getItem('user') || '{}'); const user = JSON.parse(localStorage.getItem('user') || '{}');
const avatar = document.getElementById('user-avatar'); const avatar = document.getElementById('user-avatar');
@@ -273,31 +149,11 @@ function bindLoginForm() {
const res = await api.login(username, password, totpCode, remember); const res = await api.login(username, password, totpCode, remember);
localStorage.setItem('token', res.access_token); localStorage.setItem('token', res.access_token);
localStorage.setItem('user', JSON.stringify(res.user)); localStorage.setItem('user', JSON.stringify(res.user));
// A parked SSO identity is attached to exactly the account that just
// proved itself with a password — no email guesswork involved.
if (ssoLinkPending) {
ssoLinkPending = false;
try {
const linked = await api.oidcLinkPending();
if (linked && linked.linked) {
sessionStorage.setItem('ssoLinkedName', linked.name || '');
}
} catch (e) { /* linking is optional; the login itself succeeded */ }
}
await launchApp(); await launchApp();
} catch (err) { } catch (err) {
if (err.message === '2fa_required') { if (err.message === '2fa_required') {
totpRow.classList.remove('hidden'); totpRow.classList.remove('hidden');
// Focus only after the revealed row has been laid out. Password document.getElementById('login-totp').focus();
// managers position their inline icon from the field's bounding box at
// the moment focus fires, and focusing in the same tick as the unhide
// can hand them a stale rect. setTimeout rather than
// requestAnimationFrame on purpose: rAF does not run in a backgrounded
// tab, which would swallow the focus entirely.
setTimeout(() => {
window.dispatchEvent(new Event('resize')); // nudge overlay repositioning
document.getElementById('login-totp').focus();
}, 0);
} else { } else {
errEl.textContent = err.message; errEl.textContent = err.message;
errEl.classList.remove('hidden'); errEl.classList.remove('hidden');
@@ -336,23 +192,8 @@ boot();
// ── Service Worker registration (PWA) ───────────────────── // ── Service Worker registration (PWA) ─────────────────────
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
// Auto-update: when a new service worker takes control, reload once so the
// page runs the fresh assets. Guarded so it never loops and never fires on
// the very first install (when there was no previous controller).
let refreshing = false;
const hadController = !!navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing || !hadController) return;
refreshing = true;
window.location.reload();
});
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).then(reg => { navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(err => {
// Check for a new SW now and hourly, so long-open tabs pick up releases.
reg.update();
setInterval(() => reg.update(), 60 * 60 * 1000);
}).catch(err => {
console.warn('SW registration failed:', err); console.warn('SW registration failed:', err);
}); });
}); });
+283 -1225
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -5,7 +5,7 @@
* value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or "" * value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or ""
* mode : 'datetime' | 'date' * mode : 'datetime' | 'date'
*/ */
import { t, getLocale } from './i18n.js'; import { t } from './i18n.js';
const ITEM_H = 40; // px per scroll item const ITEM_H = 40; // px per scroll item
const VISIBLE = 3; // visible items in time scroller const VISIBLE = 3; // visible items in time scroller
@@ -250,16 +250,16 @@ export function openDatePicker(anchor, value, mode = 'datetime') {
/** /**
* Format an ISO value for display in the UI * Format an ISO value for display in the UI
* mode: 'datetime' | 'date' * mode: 'datetime' | 'date'
* Formatting follows the active UI language via the i18n locale registry. * lang: 'de' | 'en'
*/ */
export function formatDtDisplay(isoStr, mode) { export function formatDtDisplay(isoStr, mode, lang = 'de') {
if (!isoStr) return '—'; if (!isoStr) return '—';
try { try {
const d = mode === 'datetime' const d = mode === 'datetime'
? new Date(isoStr.replace(' ', 'T')) ? new Date(isoStr.replace(' ', 'T'))
: new Date(isoStr + 'T00:00:00'); : new Date(isoStr + 'T00:00:00');
if (isNaN(d)) return isoStr; if (isNaN(d)) return isoStr;
const locale = getLocale(); const locale = lang === 'en' ? 'en-GB' : 'de-CH';
if (mode === 'datetime') { if (mode === 'datetime') {
return d.toLocaleString(locale, { return d.toLocaleString(locale, {
day: '2-digit', month: '2-digit', year: 'numeric', day: '2-digit', month: '2-digit', year: 'numeric',
+14 -3462
View File
File diff suppressed because it is too large Load Diff
-56
View File
@@ -1,56 +0,0 @@
// Instance-wide branding + default theme (public GET /api/instance/). Loaded as
// early as possible so the login screen is already branded. See
// backend/routers/admin_router.py.
import { setInstanceDefaults } from './settings-sync.js';
import { setCustomFavicon, applyFavicon } from './utils.js';
export let instanceConfig = {};
// Cache the bundled default logo markup so "remove logo" can restore it.
const originalLogoHtml = {};
function setBrandLogo(url) {
document.querySelectorAll('.topbar-logo, .auth-logo').forEach(el => {
const key = el.classList.contains('topbar-logo') ? 'topbar' : 'auth';
if (!(key in originalLogoHtml)) originalLogoHtml[key] = el.innerHTML;
if (url) {
const cls = key === 'topbar' ? 'topbar-logo-img' : 'auth-logo-img';
el.innerHTML = `<img class="${cls}" src="${url}" alt="Logo">`;
} else {
el.innerHTML = originalLogoHtml[key];
}
});
}
export function applyInstanceBranding(cfg) {
cfg = cfg || {};
setInstanceDefaults(cfg.default_theme || {});
setCustomFavicon(cfg.has_favicon ? cfg.favicon_url : null);
setBrandLogo(cfg.has_logo ? cfg.logo_url : null);
applyFavicon(); // no arg → uses instance/built-in primary as fallback
}
// Memoised so boot() and initCalendar() share a single fetch; callers can await
// it to guarantee instance defaults are set before the first applyTheme().
let loadPromise = null;
export function loadInstance() {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const res = await fetch('/api/instance/', { headers: { Accept: 'application/json' } });
instanceConfig = res.ok ? await res.json() : {};
} catch (_) {
instanceConfig = {};
}
applyInstanceBranding(instanceConfig);
return instanceConfig;
})();
return loadPromise;
}
// Force a re-fetch (after an admin changes branding/theme).
export function reloadInstance() {
loadPromise = null;
return loadInstance();
}
-137
View File
@@ -1,137 +0,0 @@
// Per-setting cross-device sync for the web client.
//
// The server is the sole authority for WHICH settings sync (it returns a fully
// resolved `sync_flags` map). This module owns the browser-local copy of every
// syncable value so that "not synced" works per browser, plus the declarative
// table definition the settings UI renders from. See backend/SETTINGS_SYNC.md.
import { LANGUAGES } from './i18n.js';
// Canonical default colours — the single source for the web. Reset writes these.
export const DEFAULT_COLORS = {
primary_color: '#58B900',
accent_color: '#45A148',
today_color: '#6FB669',
text_color: '#FFFFFF',
bg_color: '#000000',
line_color: '#3B3B3D',
surface_color: '#2B2B2B',
month_divider_color: '#95D25E',
month_label_color: '#95D25E',
// Fine-grained element colours (see THEME.md). day_selected/today_bg are
// applied as a subtle tint of the chosen colour; the rest are applied solid.
hover_highlight_color: '#2A2A38',
icon_inactive_color: '#90AA91',
icon_active_color: '#E8E8F0',
day_hover_color: '#2B382A',
day_selected_color: '#88EF9A',
day_bg_color: '#000000',
today_bg_color: '#477650',
};
// The syncable settings the web client exposes, grouped into table sections.
// `type`: 'select' | 'toggle' | 'color' | 'icon'.
// Option labels use i18n keys via `tk`, or a literal `label`.
export const SETTING_GROUPS = [
{
titleKey: 'settings_calendar_view',
rows: [
{ key: 'default_view', labelKey: 'settings_default_view', type: 'select', opts: [
{ v: 'month', tk: 'view_month' }, { v: 'week', tk: 'view_week' },
{ v: 'day', tk: 'view_day' }, { v: 'quarter', tk: 'view_quarter' },
{ v: 'agenda', tk: 'view_agenda' },
] },
{ key: 'week_start_day', labelKey: 'settings_week_start', type: 'select', opts: [
{ v: 'monday', tk: 'week_start_monday' }, { v: 'sunday', tk: 'week_start_sunday' },
] },
{ key: 'dim_past_events', labelKey: 'settings_dim_past', type: 'toggle' },
{ key: 'month_view_paged', labelKey: 'settings_month_mode', type: 'select', opts: [
{ v: false, tk: 'settings_month_mode_scroll' }, { v: true, tk: 'settings_month_mode_paged' },
] },
{ key: 'hour_height', labelKey: 'settings_hour_height', type: 'select', opts: [
{ v: 28, tk: 'hour_compact' }, { v: 44, tk: 'hour_normal' },
{ v: 60, tk: 'hour_comfort' }, { v: 80, tk: 'hour_large' },
] },
{ key: 'default_event_duration_minutes', labelKey: 'settings_default_duration', type: 'select', opts: [
{ v: 15, label: '15 min' }, { v: 30, label: '30 min' }, { v: 45, label: '45 min' },
{ v: 60, label: '1 h' }, { v: 90, label: '1,5 h' }, { v: 120, label: '2 h' },
] },
],
},
{
titleKey: 'settings_language',
rows: [
// Derived from the i18n registry: adding a language there adds it here.
// Each language is listed under its own endonym, so the labels are never
// translated — a Finn looks for "Suomi", not "Finnisch".
{ key: 'language', labelKey: 'settings_language', type: 'select',
opts: LANGUAGES.map(l => ({ v: l.code, label: l.label })) },
{ key: 'share_calendar_icon', labelKey: 'settings_share_icon', type: 'icon' },
],
},
{
titleKey: 'settings_colors',
rows: [
{ key: 'primary_color', labelKey: 'settings_primary_color', type: 'color' },
{ key: 'accent_color', labelKey: 'settings_accent_color', type: 'color' },
{ key: 'today_color', labelKey: 'settings_today_color', type: 'color' },
{ key: 'text_color', labelKey: 'settings_text_color', type: 'color' },
{ key: 'bg_color', labelKey: 'settings_bg_color', type: 'color' },
{ key: 'surface_color', labelKey: 'settings_surface_color', type: 'color' },
{ key: 'line_color', labelKey: 'settings_line_color', type: 'color' },
{ key: 'month_divider_color', labelKey: 'settings_month_divider_color', type: 'color' },
{ key: 'month_label_color', labelKey: 'settings_month_label_color', type: 'color' },
{ key: 'hover_highlight_color', labelKey: 'settings_hover_highlight_color', type: 'color' },
{ key: 'icon_inactive_color', labelKey: 'settings_icon_inactive_color', type: 'color' },
{ key: 'icon_active_color', labelKey: 'settings_icon_active_color', type: 'color' },
{ key: 'day_hover_color', labelKey: 'settings_day_hover_color', type: 'color' },
{ key: 'day_selected_color', labelKey: 'settings_day_selected_color', type: 'color' },
{ key: 'day_bg_color', labelKey: 'settings_day_bg_color', type: 'color' },
{ key: 'today_bg_color', labelKey: 'settings_today_bg_color', type: 'color' },
],
},
];
// Flat list of every syncable key the web manages (table order).
export const SYNCABLE_KEYS = SETTING_GROUPS.flatMap(g => g.rows.map(r => r.key));
// Instance-wide default theme set by an admin (from GET /api/instance/). It is
// the base a user inherits and the target that a per-colour "Reset" returns to.
// A user's own value always overrides it. See backend/routers/admin_router.py.
export let INSTANCE_DEFAULTS = {};
export function setInstanceDefaults(obj) { INSTANCE_DEFAULTS = obj || {}; }
// The effective default for a colour key: admin instance default → built-in.
export function baseColor(key) { return INSTANCE_DEFAULTS[key] || DEFAULT_COLORS[key]; }
const LOCAL_KEY = 'settingsLocal';
export function loadLocal() {
try { return JSON.parse(localStorage.getItem(LOCAL_KEY) || '{}') || {}; }
catch (_) { return {}; }
}
export function saveLocal(obj) {
try { localStorage.setItem(LOCAL_KEY, JSON.stringify(obj)); } catch (_) {}
}
// Effective value of a syncable key: synced → server value; otherwise the
// browser-local value (falling back to the server value if we have none yet).
export function effectiveValue(key, server, flags, local) {
if (flags[key]) return server[key];
return (key in local && local[key] != null) ? local[key] : server[key];
}
// Build the effective settings object: a copy of the raw server settings with
// each syncable key resolved to its effective value. As a side effect, mirror
// every effective value into the local copy so that flipping a flag OFF later
// retains the currently-visible value.
export function mergeEffective(server, flags, local) {
const eff = { ...server };
for (const key of SYNCABLE_KEYS) {
const val = effectiveValue(key, server, flags, local);
eff[key] = val;
if (val != null) local[key] = val;
}
saveLocal(local);
return eff;
}
+21 -102
View File
@@ -1,6 +1,3 @@
import { DEFAULT_COLORS, INSTANCE_DEFAULTS, baseColor } from './settings-sync.js';
import { getLocale } from './i18n.js';
export function isToday(d) { export function isToday(d) {
const now = new Date(); const now = new Date();
return d.getFullYear() === now.getFullYear() && return d.getFullYear() === now.getFullYear() &&
@@ -19,28 +16,8 @@ export function isPast(ev) {
return end < new Date(); return end < new Date();
} }
// Title to render for an event: the server-decorated one (birthday age, group
// prefix) wins over the raw title, which stays untouched for editing.
export function eventTitle(ev) {
return ev.display_title || ev.title || '';
}
// Small inline cake icon for birthday events, sized to the surrounding text and
// tinted with the current text colour so it matches every bar it's dropped into.
export function birthdayIconSvg() {
return '<svg viewBox="0 0 24 24" aria-hidden="true" '
+ 'style="width:0.85em;height:0.85em;vertical-align:-0.1em;margin-right:2px;flex:0 0 auto">'
+ '<path fill="currentColor" d="M12 6c1.11 0 2-.9 2-2 0-.38-.1-.73-.29-1.03L12 0l-1.71 2.97'
+ 'c-.19.3-.29.65-.29 1.03 0 1.1.9 2 2 2zm4.6 9.99l-1.07-1.07-1.08 1.07c-1.3 1.3-3.58 1.31-4.89 0'
+ 'l-1.07-1.07-1.09 1.07C6.75 16.64 5.88 17 4.96 17c-.73 0-1.4-.23-1.96-.61V21c0 .55.45 1 1 1h16'
+ 'c.55 0 1-.45 1-1v-4.61c-.56.38-1.23.61-1.96.61-.92 0-1.79-.36-2.44-1.01zM18 9h-5V7h-2v2H6'
+ 'c-1.66 0-3 1.34-3 3v1.54c0 1.08.88 1.96 1.96 1.96.52 0 1.02-.2 1.38-.57l2.14-2.13 2.13 2.13'
+ 'c.74.74 2.03.74 2.77 0l2.14-2.13 2.13 2.13c.37.37.86.57 1.38.57 1.08 0 1.96-.88 1.96-1.96V12'
+ 'c.01-1.66-1.33-3-2.99-3z"/></svg>';
}
export function formatDate(d, opts = {}) { export function formatDate(d, opts = {}) {
return d.toLocaleDateString(getLocale(), opts); return d.toLocaleDateString('de', opts);
} }
export function dateKey(d) { export function dateKey(d) {
@@ -99,26 +76,23 @@ const LINE_CONTRAST = {
4: { border: '#5a5a78', light: '#484860' }, 4: { border: '#5a5a78', light: '#484860' },
}; };
// Default-Farben: EINZIGE Quelle ist DEFAULT_COLORS in settings-sync.js. // Defaults wenn kein Custom-Override gesetzt ist.
// Dort ändern → wirkt für Reset (Tabelle) und diese Theme-Fallbacks gleichzeitig. // Bewusst hart "weiss auf schwarz" damit man nie unsichtbar landet.
export const DEFAULT_TEXT_COLOR = DEFAULT_COLORS.text_color; export const DEFAULT_TEXT_COLOR = '#FFFFFF';
export const DEFAULT_LINE_COLOR = DEFAULT_COLORS.line_color; export const DEFAULT_LINE_COLOR = '#3A3A52';
export const DEFAULT_BG_COLOR = DEFAULT_COLORS.bg_color; export const DEFAULT_BG_COLOR = '#000000';
export function applyTheme(settings) { export function applyTheme(settings) {
const root = document.documentElement; const root = document.documentElement;
// Fallback chain for a colour that has no per-user value: admin instance root.style.setProperty('--primary', settings.primary_color || '#4285f4');
// default → built-in default (baseColor()). root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || '#4285f4', 0.15));
const primary = settings.primary_color || baseColor('primary_color'); root.style.setProperty('--accent', settings.accent_color || '#ea4335');
root.style.setProperty('--primary', primary); root.style.setProperty('--today-color', settings.today_color || '#4285f4');
root.style.setProperty('--primary-dim', hexToRgba(primary, 0.15));
root.style.setProperty('--accent', settings.accent_color || baseColor('accent_color'));
root.style.setProperty('--today-color', settings.today_color || baseColor('today_color'));
// Effektive Farben bestimmen (Override > Admin-Default > eingebauter Default). // Effektive Farben bestimmen (Override > Default).
let textColor = settings.text_color || baseColor('text_color'); let textColor = settings.text_color || DEFAULT_TEXT_COLOR;
let lineColor = settings.line_color || baseColor('line_color'); let lineColor = settings.line_color || DEFAULT_LINE_COLOR;
let bgColor = settings.bg_color || baseColor('bg_color'); let bgColor = settings.bg_color || DEFAULT_BG_COLOR;
// Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug // Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug
// Kontrast haben (passiert wenn man aus Versehen text=bg eingibt), // Kontrast haben (passiert wenn man aus Versehen text=bg eingibt),
@@ -136,73 +110,18 @@ export function applyTheme(settings) {
root.style.setProperty('--border', lineColor); root.style.setProperty('--border', lineColor);
root.style.setProperty('--border-light', shadeHex(lineColor, -0.25)); root.style.setProperty('--border-light', shadeHex(lineColor, -0.25));
// Surface family (sidebar / top bar / cards). Explicit surface_color drives
// it; otherwise derive from the app background as before.
const surfaceBase = settings.surface_color || shadeHex(bgColor, 0.10);
root.style.setProperty('--bg-app', bgColor); root.style.setProperty('--bg-app', bgColor);
root.style.setProperty('--bg-topbar', surfaceBase); root.style.setProperty('--bg-topbar', shadeHex(bgColor, 0.10));
root.style.setProperty('--bg-sidebar', surfaceBase); root.style.setProperty('--bg-sidebar', shadeHex(bgColor, 0.10));
root.style.setProperty('--bg-surface', shadeHex(surfaceBase, 0.10)); root.style.setProperty('--bg-surface', shadeHex(bgColor, 0.18));
root.style.setProperty('--bg-hover', shadeHex(surfaceBase, 0.20)); root.style.setProperty('--bg-hover', shadeHex(bgColor, 0.26));
root.style.setProperty('--bg-active', shadeHex(surfaceBase, 0.34)); root.style.setProperty('--bg-active', shadeHex(bgColor, 0.40));
const hh = settings.hour_height || 44; const hh = settings.hour_height || 44;
root.style.setProperty('--hour-h', hh + 'px'); root.style.setProperty('--hour-h', hh + 'px');
root.style.setProperty('--month-divider-color', settings.month_divider_color || baseColor('month_divider_color')); root.style.setProperty('--month-divider-color', settings.month_divider_color || '#7090c0');
root.style.setProperty('--month-label-color', settings.month_label_color || baseColor('month_label_color')); root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
// Fine-grained element colours. Each is applied ONLY when the user set an
// explicit value; otherwise the :root default (which references a derived
// variable) stays in effect, so the look is unchanged until customised.
// day_selected_color / today_bg_color feed a *-base variable that CSS turns
// into a subtle tint via color-mix; the rest are applied as-is.
// User value → admin instance default (if any). When neither is set the
// property stays unset so the derived :root default keeps the current look.
const setIf = (varName, key) => {
const value = settings[key] || INSTANCE_DEFAULTS[key];
if (value) root.style.setProperty(varName, value);
};
setIf('--hover-highlight', 'hover_highlight_color');
setIf('--icon-inactive-color', 'icon_inactive_color');
setIf('--icon-active-color', 'icon_active_color');
setIf('--day-hover-color', 'day_hover_color');
setIf('--day-selected-base', 'day_selected_color');
setIf('--day-bg', 'day_bg_color');
setIf('--today-bg-base', 'today_bg_color');
}
// Instance custom favicon URL (set by the branding loader). When present it
// overrides the primary-colour tinting.
let customFaviconUrl = null;
export function setCustomFavicon(url) { customFaviconUrl = url || null; }
// Tint the favicon (and browser theme-colour) to the current primary colour so
// the tab icon reflects the user's theme. Called at load and after saving —
// NOT on every live keystroke. Reuses the calendar glyph from favicon.svg.
export function applyFavicon(primaryColor) {
const color = primaryColor || baseColor('primary_color');
let link = document.querySelector('link[rel="icon"]');
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
if (customFaviconUrl) {
// Admin-uploaded favicon: use as-is (no primary-colour tinting).
link.type = 'image/png';
link.href = customFaviconUrl;
} else {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">`
+ '<rect x="3" y="4" width="18" height="18" rx="2"/>'
+ '<line x1="16" y1="2" x2="16" y2="6"/>'
+ '<line x1="8" y1="2" x2="8" y2="6"/>'
+ '<line x1="3" y1="10" x2="21" y2="10"/></svg>';
link.type = 'image/svg+xml';
link.href = 'data:image/svg+xml;base64,' + btoa(svg);
}
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', color);
} }
function luminance(hex) { function luminance(hex) {
+1 -1
View File
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change // Increment APP_VERSION with every code change
export const APP_VERSION = 'v95'; export const APP_VERSION = 'v62';
+4 -4
View File
@@ -1,5 +1,5 @@
import { isPast, eventTitle, birthdayIconSvg } from '../utils.js'; import { isPast } from '../utils.js';
import { t, getLocale } from '../i18n.js'; import { t, getLang } from '../i18n.js';
export function renderAgenda(container, currentDate, events, onEventClick) { export function renderAgenda(container, currentDate, events, onEventClick) {
if (!events.length) { if (!events.length) {
@@ -45,7 +45,7 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
return `<div class="agenda-event ${pastCls}" data-id="${ev.id}" data-url="${escAttr(ev.url)}"> return `<div class="agenda-event ${pastCls}" data-id="${ev.id}" data-url="${escAttr(ev.url)}">
<div class="agenda-ev-color" style="background:${color}"></div> <div class="agenda-ev-color" style="background:${color}"></div>
<div class="agenda-ev-info"> <div class="agenda-ev-info">
<div class="agenda-ev-title">${ev.is_birthday ? birthdayIconSvg() : ''}${escHtml(eventTitle(ev))}</div> <div class="agenda-ev-title">${escHtml(ev.title)}</div>
<div class="agenda-ev-meta">${timeStr}${locHtml}</div> <div class="agenda-ev-meta">${timeStr}${locHtml}</div>
</div> </div>
</div>`; </div>`;
@@ -94,7 +94,7 @@ function isTodayDate(d) {
} }
function fmtTime(d) { function fmtTime(d) {
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString(getLang(), { hour: '2-digit', minute: '2-digit' });
} }
function escHtml(s) { function escHtml(s) {
+9 -10
View File
@@ -1,5 +1,5 @@
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js'; import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
import { t, getLocale } from '../i18n.js'; import { t } from '../i18n.js';
const LANE_H = 20; // px per lane (event height 18px + 2px gap) const LANE_H = 20; // px per lane (event height 18px + 2px gap)
const DAY_H = 30; // day-number row height const DAY_H = 30; // day-number row height
@@ -110,15 +110,14 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
const pastCls = isPast(ev) ? 'past' : ''; const pastCls = isPast(ev) ? 'past' : '';
const cL = continuesLeft ? 'continues-left' : ''; const cL = continuesLeft ? 'continues-left' : '';
const cR = continuesRight ? 'continues-right' : ''; const cR = continuesRight ? 'continues-right' : '';
const titleEsc = escHtml(eventTitle(ev)); const titleEsc = escHtml(ev.title);
const icon = ev.is_birthday ? birthdayIconSvg() : '';
const labelHtml = ev.allDay const labelHtml = ev.allDay
? icon + titleEsc ? titleEsc
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${icon}${titleEsc}`; : `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${titleEsc}`;
eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}" eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" data-id="${ev.id}" data-url="${escAttr(ev.url)}"
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}" style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}"
title="${escAttr(eventTitle(ev))}">${labelHtml}</div>`; title="${escAttr(ev.title)}">${labelHtml}</div>`;
}); });
// "+N more" per column // "+N more" per column
@@ -251,7 +250,7 @@ function dateKey(d) {
} }
function fmtTime(d) { function fmtTime(d) {
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
} }
function escHtml(s) { function escHtml(s) {
@@ -293,7 +292,7 @@ function showOverflowPopup(anchor, date, events, onEventClick) {
+ (continuesLeft ? ' continues-left' : '') + (continuesLeft ? ' continues-left' : '')
+ (continuesRight ? ' continues-right' : ''); + (continuesRight ? ' continues-right' : '');
bar.style.background = color; bar.style.background = color;
bar.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev)); bar.textContent = ev.title;
bar.addEventListener('click', e => { bar.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
popup.remove(); popup.remove();
@@ -315,7 +314,7 @@ function showOverflowPopup(anchor, date, events, onEventClick) {
const title = document.createElement('span'); const title = document.createElement('span');
title.className = 'mop-title'; title.className = 'mop-title';
title.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev)); title.textContent = ev.title;
row.append(dot, time, title); row.append(dot, time, title);
row.addEventListener('click', e => { row.addEventListener('click', e => {
+2 -2
View File
@@ -1,4 +1,4 @@
import { isToday, isPast, dayOfWeek, eventTitle } from '../utils.js'; import { isToday, isPast, dayOfWeek } from '../utils.js';
import { t } from '../i18n.js'; import { t } from '../i18n.js';
export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
@@ -64,7 +64,7 @@ export function renderQuarter(container, currentDate, events, onDayClick, onEven
const dots = cellEvs.slice(0, 3).map(ev => { const dots = cellEvs.slice(0, 3).map(ev => {
const color = ev.color || ev.calendarColor || '#4285f4'; const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : ''; const pastCls = isPast(ev) ? 'past' : '';
return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(eventTitle(ev))}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`; return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(ev.title)}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`;
}).join(''); }).join('');
const moreDot = cellEvs.length > 3 const moreDot = cellEvs.length > 3
? `<span class="qtr-dot-more">+${cellEvs.length - 3}</span>` ? `<span class="qtr-dot-more">+${cellEvs.length - 3}</span>`
+8 -10
View File
@@ -1,5 +1,5 @@
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js'; import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
import { t, getLocale } from '../i18n.js'; import { t } from '../i18n.js';
export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) { export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) {
// Build the days array (7 days for week, 1 for day) // Build the days array (7 days for week, 1 for day)
@@ -77,12 +77,11 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const cL = evStart < firstDay ? 'continues-left' : ''; const cL = evStart < firstDay ? 'continues-left' : '';
const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : ''; const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : '';
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart]) const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
? `${fmtTime(new Date(ev.start))} ${eventTitle(ev)}` ? `${fmtTime(new Date(ev.start))} ${ev.title}`
: eventTitle(ev); : ev.title;
const icon = ev.is_birthday ? birthdayIconSvg() : '';
return `<div class="allday-span ${pastCls} ${multiCls} ${cL} ${cR}" return `<div class="allday-span ${pastCls} ${multiCls} ${cL} ${cR}"
style="left:calc(${left.toFixed(2)}% + 1px);width:calc(${width.toFixed(2)}% - 2px);top:${top}px;background:${color};color:#fff" style="left:calc(${left.toFixed(2)}% + 1px);width:calc(${width.toFixed(2)}% - 2px);top:${top}px;background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}">${icon}${escHtml(label)}</div>`; data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(label)}</div>`;
}).join(''); }).join('');
const alldayBgCols = days.map(day => const alldayBgCols = days.map(day =>
@@ -130,12 +129,11 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const isShort = height < 34; const isShort = height < 34;
const shortCls = isShort ? 'short' : ''; const shortCls = isShort ? 'short' : '';
const locHtml = (!isShort && ev.location) ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : ''; const locHtml = (!isShort && ev.location) ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
const icon = ev.is_birthday ? birthdayIconSvg() : '';
return `<div class="timed-event ${pastCls} ${shortCls}" return `<div class="timed-event ${pastCls} ${shortCls}"
style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff" style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}"> data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">
<div class="ev-time">${startStr}</div> <div class="ev-time">${startStr}</div>
<div class="ev-title">${icon}${escHtml(eventTitle(ev))}</div> <div class="ev-title">${escHtml(ev.title)}</div>
${locHtml} ${locHtml}
</div>`; </div>`;
}).join(''); }).join('');
@@ -352,7 +350,7 @@ function isSameDay(a, b) {
} }
function fmtTime(d) { function fmtTime(d) {
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
} }
function escHtml(s) { function escHtml(s) {
+5 -16
View File
@@ -1,17 +1,12 @@
{ {
"id": "/",
"name": "Calendarr", "name": "Calendarr",
"short_name": "Calendarr", "short_name": "Calendarr",
"description": "Dein privater, selbst gehosteter Kalender.",
"lang": "de",
"dir": "ltr",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#0e0e14", "background_color": "#0e0e14",
"theme_color": "#16713d", "theme_color": "#4285f4",
"categories": ["productivity", "utilities"],
"icons": [ "icons": [
{ {
"src": "/icons/icon-192.png", "src": "/icons/icon-192.png",
@@ -26,16 +21,10 @@
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/icons/icon-maskable-192.png", "src": "/icons/icon.svg",
"sizes": "192x192", "sizes": "any",
"type": "image/png", "type": "image/svg+xml",
"purpose": "maskable" "purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
] ]
} }
+1 -7
View File
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next // the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required. // reload, no manual SW unregister required.
const CACHE_VERSION = 'calendarr-v41'; const CACHE_VERSION = 'calendarr-v23';
const OFFLINE_SHELL = ['/', '/index.html']; const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => { self.addEventListener('install', event => {
@@ -34,12 +34,6 @@ self.addEventListener('fetch', event => {
const url = new URL(req.url); const url = new URL(req.url);
// SSO: let the browser handle these itself. /start and /callback are
// redirects that must be followed as a top-level navigation and carry the
// flow cookie; wrapping them in fetch() would risk rendering the JSON
// offline fallback in the address bar.
if (url.pathname.startsWith('/api/auth/oidc/')) return;
// API routes: always go to the network, no offline fallback (we'd just // API routes: always go to the network, no offline fallback (we'd just
// be returning stale account/event data otherwise). // be returning stale account/event data otherwise).
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
-1
View File
@@ -12,4 +12,3 @@ qrcode[pil]==8.0
Pillow==11.0.0 Pillow==11.0.0
python-dateutil==2.9.0 python-dateutil==2.9.0
websocket-client==1.8.0 websocket-client==1.8.0
Authlib==1.3.2