Compare commits
@@ -106,6 +106,93 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 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.
|
||||||
@@ -20,7 +20,14 @@ 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
|
||||||
|
# (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"))
|
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:
|
||||||
@@ -36,6 +43,18 @@ 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:
|
||||||
@@ -46,11 +65,22 @@ 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 not username:
|
if uid is None and not username:
|
||||||
raise exc
|
raise exc
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
|
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()
|
user = db.query(models.User).filter(models.User.username == username).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise exc
|
raise exc
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""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/"
|
||||||
@@ -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",
|
"source", "type", "owner", "is_group_event", "display_color", "read_only",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -102,12 +102,14 @@ 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.
|
by the group combined view. ``read_only`` marks events the requester may not
|
||||||
|
edit (someone else's calendar), so clients can hide edit/delete.
|
||||||
"""
|
"""
|
||||||
d = {
|
d = {
|
||||||
"id": ev.uid,
|
"id": ev.uid,
|
||||||
@@ -130,10 +132,31 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -146,6 +169,7 @@ 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 = []
|
||||||
@@ -177,6 +201,7 @@ 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)
|
||||||
@@ -203,11 +228,13 @@ 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
|
||||||
|
|||||||
@@ -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 auth_router, caldav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
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
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
@@ -225,6 +225,163 @@ 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)
|
||||||
@@ -263,15 +420,21 @@ 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")
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
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
|
||||||
@@ -17,6 +19,12 @@ 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"
|
||||||
@@ -36,6 +44,9 @@ 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:
|
||||||
@@ -97,6 +108,18 @@ 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")
|
||||||
@@ -110,10 +133,54 @@ 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"
|
||||||
|
|
||||||
@@ -122,8 +189,22 @@ 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")
|
||||||
@@ -152,6 +233,13 @@ 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")
|
||||||
@@ -166,6 +254,8 @@ 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)
|
||||||
@@ -282,6 +372,22 @@ 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"
|
||||||
|
|
||||||
@@ -317,6 +423,26 @@ 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."""
|
||||||
|
|
||||||
@@ -332,3 +458,33 @@ 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")
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""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}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""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"
|
||||||
@@ -97,8 +97,44 @@ 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 calendars."""
|
"""All local calendar ids the user may read: own + shared + group + co-member group-visible."""
|
||||||
ids: set[int] = set()
|
ids: set[int] = set()
|
||||||
|
|
||||||
own = (
|
own = (
|
||||||
@@ -123,4 +159,18 @@ 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""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}
|
||||||
@@ -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_access_token, get_current_user, get_password_hash, verify_password
|
from auth import create_user_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_access_token({"sub": user.username})
|
token = create_user_token(user)
|
||||||
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_access_token({"sub": user.username})
|
token = create_user_token(user)
|
||||||
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_access_token({"sub": user.username}, expires_delta=expires)
|
token = create_user_token(user, 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)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""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
|
||||||
|
]
|
||||||
@@ -300,6 +300,7 @@ 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(
|
||||||
@@ -333,6 +334,12 @@ 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)
|
||||||
@@ -345,6 +352,23 @@ 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 = {}
|
||||||
|
|
||||||
@@ -354,6 +378,15 @@ 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(
|
||||||
@@ -384,6 +417,12 @@ 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,
|
||||||
@@ -414,13 +453,18 @@ 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:
|
||||||
all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
|
g_events, g_errors = 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)
|
||||||
google_errors.append({"email": g_acc.email})
|
sync_errors.append({
|
||||||
|
"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
|
||||||
@@ -431,11 +475,18 @@ def get_events(
|
|||||||
)
|
)
|
||||||
for ha_acc in ha_accounts:
|
for ha_acc in ha_accounts:
|
||||||
try:
|
try:
|
||||||
all_events.extend(get_ha_events(ha_acc, start_dt, end_dt, db))
|
ha_events, ha_errors = 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": google_errors}
|
return {"events": all_events, "errors": sync_errors}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/events")
|
@router.post("/events")
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
"""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)
|
||||||
@@ -382,15 +382,35 @@ def update_calendar(
|
|||||||
|
|
||||||
# ── Events ───────────────────────────────────────────────
|
# ── Events ───────────────────────────────────────────────
|
||||||
|
|
||||||
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
|
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple:
|
||||||
"""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)
|
||||||
raise
|
for gcal in account.calendars:
|
||||||
|
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
|
||||||
@@ -409,8 +429,14 @@ 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
|
return all_events, errors
|
||||||
|
|
||||||
|
|
||||||
class GoogleEventCreate(BaseModel):
|
class GoogleEventCreate(BaseModel):
|
||||||
|
|||||||
@@ -176,11 +176,22 @@ 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 {
|
||||||
@@ -317,17 +328,12 @@ 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 so every client (web,
|
"""Server-side display title for the combined view. The former owner/creator
|
||||||
iOS, Android) renders identically: another member's / creator's first name
|
first-name prefix ("Guido: …") was dropped: each member already has a
|
||||||
is prefixed. No icon glyph is embedded — group icons are semantic keys the
|
distinct colour, so the prefix was redundant noise. We still return a
|
||||||
clients render as native vector icons, and group-calendar events are
|
non-empty `display_title` (== raw title) so the clients' legacy fallback —
|
||||||
distinguished by their (group) colour. The raw `title` stays for editing."""
|
which rebuilds a prefix when `display_title` is empty — never kicks in.
|
||||||
if is_group:
|
`display_color` continues to carry the per-person colour."""
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -374,6 +380,9 @@ 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(
|
||||||
@@ -399,9 +408,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)
|
built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)
|
||||||
else:
|
else:
|
||||||
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group)]
|
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)]
|
||||||
|
|
||||||
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":
|
||||||
@@ -411,6 +420,9 @@ 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
|
||||||
|
# build_local_event_dict — keep it rather than clobber the age.
|
||||||
|
if not b.get("is_birthday"):
|
||||||
b["display_title"] = _decorate_title(
|
b["display_title"] = _decorate_title(
|
||||||
b.get("title", ""), is_group=is_group, creator=b.get("creator"),
|
b.get("title", ""), is_group=is_group, creator=b.get("creator"),
|
||||||
owner=owner, me_id=current_user.id,
|
owner=owner, me_id=current_user.id,
|
||||||
|
|||||||
@@ -313,13 +313,32 @@ 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) -> list:
|
def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple:
|
||||||
|
"""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)
|
||||||
raise
|
for cal in account.calendars:
|
||||||
|
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
|
||||||
@@ -330,7 +349,13 @@ 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)
|
||||||
return all_events
|
errors.append({
|
||||||
|
"source": "homeassistant",
|
||||||
|
"name": f"{account.name} – {cal.name}",
|
||||||
|
"calendar_id": cal.id,
|
||||||
|
"message": "Sync fehlgeschlagen",
|
||||||
|
})
|
||||||
|
return all_events, errors
|
||||||
|
|
||||||
|
|
||||||
# ── Serialization ─────────────────────────────────────────
|
# ── Serialization ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
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
|
||||||
@@ -28,6 +31,7 @@ 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
|
||||||
|
|
||||||
@@ -39,19 +43,74 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _fetch_ics(url: str) -> str:
|
_MAX_ICS_BYTES = 5 * 1024 * 1024
|
||||||
"""Download .ics content from a URL."""
|
_MAX_REDIRECTS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _host_is_public(host: str) -> bool:
|
||||||
|
"""False if the host resolves to any private/loopback/link-local address."""
|
||||||
try:
|
try:
|
||||||
resp = http_requests.get(url, timeout=30, allow_redirects=True)
|
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:
|
||||||
|
"""Download .ics content, blocking SSRF to internal/private hosts.
|
||||||
|
|
||||||
|
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:
|
||||||
|
for _ in range(_MAX_REDIRECTS + 1):
|
||||||
|
_validate_public_url(url)
|
||||||
|
resp = http_requests.get(url, timeout=30, allow_redirects=False, stream=True)
|
||||||
|
if resp.is_redirect and resp.headers.get("location"):
|
||||||
|
url = urljoin(url, resp.headers["location"])
|
||||||
|
resp.close()
|
||||||
|
continue
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
resp.encoding = 'utf-8'
|
resp.encoding = "utf-8"
|
||||||
return resp.text
|
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}")
|
||||||
|
|
||||||
@@ -216,6 +275,8 @@ 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:
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ 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, UploadFile, File
|
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, 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
|
||||||
@@ -24,13 +25,19 @@ 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):
|
||||||
@@ -45,6 +52,8 @@ 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):
|
||||||
@@ -59,6 +68,8 @@ 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):
|
||||||
@@ -67,16 +78,29 @@ 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) -> dict:
|
shared_by: Optional[str] = None, permission: Optional[str] = None,
|
||||||
|
color_override: Optional[str] = None,
|
||||||
|
request: Optional[Request] = None) -> dict:
|
||||||
d = {
|
d = {
|
||||||
"id": cal.id,
|
"id": cal.id,
|
||||||
"name": cal.name,
|
# A shared calendar is labelled by the person/group it comes from — the
|
||||||
"color": cal.color,
|
# owner's real calendar name must never reach recipients.
|
||||||
|
"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:
|
||||||
@@ -92,6 +116,7 @@ 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),
|
||||||
):
|
):
|
||||||
@@ -109,6 +134,9 @@ 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)
|
||||||
@@ -117,7 +145,7 @@ def list_calendars(
|
|||||||
)
|
)
|
||||||
result = []
|
result = []
|
||||||
for c in own:
|
for c in own:
|
||||||
d = _cal_dict(c, owned=True)
|
d = _cal_dict(c, owned=True, request=request)
|
||||||
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
|
||||||
@@ -141,6 +169,7 @@ 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
|
||||||
@@ -155,9 +184,28 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -167,10 +215,27 @@ 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()
|
||||||
@@ -182,6 +247,7 @@ 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),
|
||||||
):
|
):
|
||||||
@@ -201,10 +267,112 @@ 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()
|
||||||
return {"ok": True}
|
db.refresh(cal)
|
||||||
|
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}")
|
||||||
@@ -241,6 +409,37 @@ 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
|
||||||
|
# "contact:<deviceId>:<contactId>") must NEVER create duplicates. If a row
|
||||||
|
# with this external_uid already exists in this calendar, update it in place
|
||||||
|
# instead of inserting a new one. Enforced server-side, so the client can
|
||||||
|
# never duplicate — even without the reconcile read.
|
||||||
|
existing = None
|
||||||
|
if data.external_uid:
|
||||||
|
existing = (
|
||||||
|
db.query(models.LocalEvent)
|
||||||
|
.filter(
|
||||||
|
models.LocalEvent.calendar_id == cal.id,
|
||||||
|
models.LocalEvent.external_uid == data.external_uid,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
ev = models.LocalEvent(
|
||||||
calendar_id=cal.id,
|
calendar_id=cal.id,
|
||||||
uid=str(uuid.uuid4()),
|
uid=str(uuid.uuid4()),
|
||||||
@@ -253,10 +452,13 @@ def create_event(
|
|||||||
color=data.color,
|
color=data.color,
|
||||||
rrule=data.rrule,
|
rrule=data.rrule,
|
||||||
is_private=data.private,
|
is_private=data.private,
|
||||||
reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None),
|
reminders=reminders,
|
||||||
|
external_uid=data.external_uid,
|
||||||
|
birth_year=data.birth_year,
|
||||||
creator_id=current_user.id, # server-side, never from the client
|
creator_id=current_user.id, # server-side, never from the client
|
||||||
)
|
)
|
||||||
db.add(ev)
|
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)
|
||||||
@@ -305,6 +507,12 @@ 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}
|
||||||
|
|
||||||
@@ -316,11 +524,48 @@ 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")
|
||||||
@@ -441,9 +686,12 @@ 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:
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
"""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,
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -15,7 +17,7 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
import models
|
import models
|
||||||
from auth import create_access_token, get_current_user, get_password_hash, verify_password
|
from auth import create_user_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()
|
||||||
@@ -32,6 +34,7 @@ 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:
|
||||||
@@ -64,6 +67,7 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -107,11 +111,17 @@ 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.
|
||||||
result["access_token"] = create_access_token({"sub": new_login})
|
# The session survives a rename on its own (uid), but the token
|
||||||
|
# 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
|
||||||
|
|
||||||
@@ -168,7 +178,11 @@ 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(user_id: int, db: Session = Depends(get_db)):
|
def get_user_avatar(
|
||||||
|
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")
|
||||||
@@ -264,3 +278,74 @@ 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,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
@@ -11,6 +12,56 @@ 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
|
||||||
@@ -27,11 +78,22 @@ 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:
|
||||||
@@ -51,11 +113,22 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -95,11 +168,42 @@ 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", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"}
|
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"}
|
||||||
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)
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ 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,
|
||||||
@@ -53,7 +57,10 @@ def user_directory(
|
|||||||
"""
|
"""
|
||||||
users = (
|
users = (
|
||||||
db.query(models.User)
|
db.query(models.User)
|
||||||
.filter(models.User.id != current_user.id)
|
.filter(
|
||||||
|
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()
|
||||||
)
|
)
|
||||||
@@ -99,6 +106,28 @@ 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,
|
||||||
|
|||||||
@@ -409,3 +409,106 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,779 @@
|
|||||||
|
"""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
|
||||||
@@ -23,6 +23,17 @@
|
|||||||
--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);
|
||||||
@@ -52,6 +63,9 @@ 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; }
|
||||||
@@ -165,6 +179,28 @@ 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;
|
||||||
@@ -179,7 +215,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(--bg-hover); color: var(--text-1); }
|
.icon-btn:hover { background: var(--hover-highlight); 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);
|
||||||
@@ -288,7 +324,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(--bg-hover); }
|
.cal-select-option:hover { background: var(--hover-highlight); }
|
||||||
.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 ──────────────────────────── */
|
||||||
@@ -459,7 +495,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(--bg-hover); color: var(--text-1); }
|
.view-btn:hover { background: var(--hover-highlight); 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; }
|
||||||
@@ -488,7 +524,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(--bg-hover); color: var(--text-1); }
|
.dropdown-item:hover { background: var(--hover-highlight); color: var(--text-1); }
|
||||||
.dropdown-item svg { flex-shrink: 0;
|
.dropdown-item svg { flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,12 +564,37 @@ 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(--bg-hover); }
|
.mini-btn:hover { color: var(--text-1); background: var(--hover-highlight); }
|
||||||
.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; }
|
||||||
@@ -544,13 +605,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(--bg-hover); color: var(--text-1); }
|
.mini-day:hover { background: var(--day-hover-color); 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: var(--primary-dim); color: var(--primary); font-weight: 600; }
|
.mini-day.selected:not(.today) { background: color-mix(in srgb, var(--day-selected-base) 15%, transparent); 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;
|
||||||
@@ -583,7 +644,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(--bg-hover); }
|
.add-cal-dropdown button:hover { background: var(--hover-highlight); }
|
||||||
.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;
|
||||||
@@ -591,7 +652,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(--bg-hover); }
|
.cal-item:hover { background: var(--hover-highlight); }
|
||||||
.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;
|
||||||
}
|
}
|
||||||
@@ -608,11 +669,16 @@ 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; }
|
||||||
.cal-item-bell { display: none; flex-shrink: 0; }
|
/* Sidebar action icons (bell / hide / delete / read-only flag) share two theme
|
||||||
|
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(--text-3); }
|
.cal-item-bell.off { display: inline-flex; opacity: .55; color: var(--icon-inactive-color); }
|
||||||
.cal-item:hover .cal-item-bell.off { opacity: 1; color: inherit; }
|
.cal-item:hover .cal-item-bell.off { opacity: 1; color: var(--icon-inactive-color); }
|
||||||
|
.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; }
|
||||||
@@ -645,11 +711,12 @@ 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(--bg-hover); }
|
.month-col:hover { background: var(--day-hover-color); }
|
||||||
.month-col.today { background: color-mix(in srgb, var(--today-color) 10%, transparent); }
|
.month-col.today { background: color-mix(in srgb, var(--today-bg-base) 10%, transparent); }
|
||||||
.month-col.month-selected { background: var(--primary-dim); }
|
.month-col.month-selected { background: color-mix(in srgb, var(--day-selected-base) 15%, transparent); }
|
||||||
.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); }
|
||||||
@@ -770,7 +837,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(--bg-hover); }
|
.mop-row:hover { background: var(--hover-highlight); }
|
||||||
.mop-dot {
|
.mop-dot {
|
||||||
width: 8px; height: 8px; border-radius: 50%;
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -831,7 +898,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(--bg-hover); }
|
.week-day-header:hover { background: var(--day-hover-color); }
|
||||||
.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);
|
||||||
@@ -983,7 +1050,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(--bg-hover); }
|
.qtr-cell:hover { background: var(--day-hover-color); }
|
||||||
.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;
|
||||||
@@ -1046,7 +1113,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(--bg-hover); }
|
.agenda-event:hover { background: var(--day-hover-color); }
|
||||||
.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; }
|
||||||
@@ -1098,7 +1165,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(--bg-hover); }
|
.rec-day-btn:hover { background: var(--hover-highlight); }
|
||||||
.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 ──────────────────────────────────── */
|
||||||
@@ -1111,7 +1178,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(--bg-hover); }
|
.ctx-item:hover { background: var(--hover-highlight); }
|
||||||
|
|
||||||
/* ── Event Popup ──────────────────────────────────────────
|
/* ── Event Popup ──────────────────────────────────────────
|
||||||
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
|
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
|
||||||
@@ -1235,7 +1302,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(--bg-hover); }
|
.popup-copy-item:hover { background: var(--hover-highlight); }
|
||||||
.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;
|
||||||
@@ -1250,6 +1317,10 @@ 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%;
|
||||||
@@ -1260,6 +1331,7 @@ 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 {
|
||||||
@@ -1270,6 +1342,7 @@ 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;
|
||||||
@@ -1278,13 +1351,18 @@ 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(--bg-hover); color: var(--text-1); }
|
.settings-nav-btn:hover { background: var(--hover-highlight); 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; }
|
||||||
.settings-panel.active { display: block; }
|
/* Constrain content to a comfortable reading column so fields don't stretch
|
||||||
|
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 {
|
||||||
@@ -1311,7 +1389,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(--bg-hover); color: var(--text-1); }
|
.contrast-btn:hover { background: var(--hover-highlight); 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; }
|
||||||
@@ -1327,6 +1405,131 @@ 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; }
|
||||||
@@ -1453,7 +1656,18 @@ 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; }
|
||||||
@@ -1468,6 +1682,28 @@ 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; }
|
||||||
@@ -1561,7 +1797,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(--bg-hover); color: var(--text-1); }
|
.dtp-nav-btn:hover { background: var(--hover-highlight); 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);
|
||||||
@@ -1578,9 +1814,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(--bg-hover); }
|
.dtp-day:hover { background: var(--day-hover-color); }
|
||||||
.dtp-day.other { color: var(--text-3); }
|
.dtp-day.other { color: var(--text-3); }
|
||||||
.dtp-day.other:hover { background: var(--bg-hover); }
|
.dtp-day.other:hover { background: var(--day-hover-color); }
|
||||||
.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;
|
||||||
@@ -1992,12 +2228,57 @@ 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 ─────────────────────────────────────────────────── */
|
||||||
@@ -2129,6 +2410,8 @@ 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,4 +1,4 @@
|
|||||||
<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">
|
<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">
|
||||||
<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 |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -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="#4285f4"/>
|
<rect width="512" height="512" fill="#16713d"/>
|
||||||
<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 |
@@ -7,7 +7,8 @@
|
|||||||
<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="#4285f4" />
|
<meta name="theme-color" content="#16713d" />
|
||||||
|
<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" />
|
||||||
@@ -76,9 +77,16 @@
|
|||||||
<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()">© 2026 Scarriffleservices</button>
|
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,10 +166,19 @@
|
|||||||
<!-- SIDEBAR -->
|
<!-- SIDEBAR -->
|
||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-inner">
|
<div class="sidebar-inner">
|
||||||
<button class="btn btn-fab" id="btn-create-event">
|
<div class="create-split add-cal-dropdown-wrap">
|
||||||
|
<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">
|
||||||
@@ -197,6 +214,7 @@
|
|||||||
</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>
|
||||||
@@ -477,6 +495,24 @@
|
|||||||
</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">×</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">
|
||||||
@@ -589,6 +625,44 @@
|
|||||||
</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">×</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">
|
||||||
@@ -693,7 +767,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_users">Benutzerverwaltung</button>
|
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_admin">Admin</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="settings-panels">
|
<div class="settings-panels">
|
||||||
@@ -714,7 +788,31 @@
|
|||||||
<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>
|
||||||
@@ -725,15 +823,12 @@
|
|||||||
<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">
|
||||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_default_duration">Standard-Termindauer</h4>
|
<label class="checkbox-row">
|
||||||
<div class="contrast-selector" id="cfg-event-duration">
|
<input type="checkbox" id="cfg-directory-hidden" />
|
||||||
<button class="contrast-btn" data-val="15">15 min</button>
|
<span data-i18n="settings_directory_hidden">Profil verbergen (nicht in Teilen-/Gruppen-Auswahl anzeigen)</span>
|
||||||
<button class="contrast-btn" data-val="30">30 min</button>
|
</label>
|
||||||
<button class="contrast-btn" data-val="45">45 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="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>
|
||||||
@@ -744,115 +839,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Darstellung: Kalenderansicht, Sprache, Stundenhöhe, Farben -->
|
<!-- Darstellung: einheitliche Sync-Tabelle (Sync-Icon | Name | Wert) -->
|
||||||
<div class="settings-panel" id="settings-panel-general">
|
<div class="settings-panel" id="settings-panel-general">
|
||||||
|
<div class="sync-global-row">
|
||||||
<h4 class="panel-title" data-i18n="settings_calendar_view">Kalenderansicht</h4>
|
<div class="sync-global-text">
|
||||||
<div class="form-group">
|
<span class="sync-global-title" data-i18n="settings_sync_all">Alle synchronisieren</span>
|
||||||
<label data-i18n="settings_default_view">Standardansicht</label>
|
<span class="panel-desc" data-i18n="settings_sync_all_desc">Diese Einstellungen zwischen deinen Geräten teilen</span>
|
||||||
<select id="cfg-default-view">
|
|
||||||
<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="form-group">
|
<button type="button" class="sync-toggle sync-toggle-lg" id="cfg-sync-all" role="switch" aria-checked="false"></button>
|
||||||
<label data-i18n="settings_week_start">Erster Wochentag</label>
|
|
||||||
<select id="cfg-week-start">
|
|
||||||
<option value="monday" data-i18n="week_start_monday">Montag</option>
|
|
||||||
<option value="sunday" data-i18n="week_start_sunday">Sonntag</option>
|
|
||||||
</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- & 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 class="settings-table" id="settings-appearance-table"></div>
|
||||||
|
<div class="theme-io-row">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-export" data-i18n="theme_export">Theme exportieren</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-import" data-i18n="theme_import">Theme importieren</button>
|
||||||
|
<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>
|
||||||
|
<input type="file" id="cfg-theme-file" accept=".theme.json,.json,.theme,application/json" hidden />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -861,15 +862,19 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<!-- Benutzerverwaltung -->
|
<!-- Admin: Benutzerverwaltung + Standard-Theme + Branding -->
|
||||||
<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>
|
||||||
@@ -888,6 +893,45 @@
|
|||||||
</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 -->
|
||||||
@@ -979,7 +1023,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Bestätigungscode eingeben</label>
|
<label>Bestätigungscode eingeben</label>
|
||||||
<input type="text" id="2fa-verify-code" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
|
<!-- data-bwignore/-1p-ignore/-lpignore: this is the *enrolment* field for a
|
||||||
|
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,6 +1,11 @@
|
|||||||
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 = {};
|
||||||
@@ -20,7 +25,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 && !path.startsWith('/auth/login')) {
|
if (res.status === 401 && !NO_RELOAD_ON_401.some(p => path.startsWith(p))) {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -108,4 +113,12 @@ 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}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,116 @@
|
|||||||
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 {
|
||||||
@@ -23,18 +130,28 @@ 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 token
|
await api.get('/auth/me'); // validate the TOKEN only
|
||||||
await launchApp();
|
authed = true;
|
||||||
return;
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
// The token is genuinely invalid/expired — clear it and show login.
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('user');
|
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();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showScreen('login');
|
showScreen('login');
|
||||||
bindLoginForm();
|
bindLoginForm();
|
||||||
|
renderSsoButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showScreen(name) {
|
function showScreen(name) {
|
||||||
@@ -50,6 +167,13 @@ 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');
|
||||||
@@ -149,11 +273,31 @@ 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
|
||||||
|
// 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();
|
document.getElementById('login-totp').focus();
|
||||||
|
}, 0);
|
||||||
} else {
|
} else {
|
||||||
errEl.textContent = err.message;
|
errEl.textContent = err.message;
|
||||||
errEl.classList.remove('hidden');
|
errEl.classList.remove('hidden');
|
||||||
@@ -192,8 +336,23 @@ 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: '/' }).catch(err => {
|
navigator.serviceWorker.register('/sw.js', { scope: '/' }).then(reg => {
|
||||||
|
// 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } from './i18n.js';
|
import { t, getLocale } 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'
|
||||||
* lang: 'de' | 'en'
|
* Formatting follows the active UI language via the i18n locale registry.
|
||||||
*/
|
*/
|
||||||
export function formatDtDisplay(isoStr, mode, lang = 'de') {
|
export function formatDtDisplay(isoStr, mode) {
|
||||||
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 = lang === 'en' ? 'en-GB' : 'de-CH';
|
const locale = getLocale();
|
||||||
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',
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
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() &&
|
||||||
@@ -16,8 +19,28 @@ 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('de', opts);
|
return d.toLocaleDateString(getLocale(), opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dateKey(d) {
|
export function dateKey(d) {
|
||||||
@@ -76,23 +99,26 @@ const LINE_CONTRAST = {
|
|||||||
4: { border: '#5a5a78', light: '#484860' },
|
4: { border: '#5a5a78', light: '#484860' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Defaults wenn kein Custom-Override gesetzt ist.
|
// Default-Farben: EINZIGE Quelle ist DEFAULT_COLORS in settings-sync.js.
|
||||||
// Bewusst hart "weiss auf schwarz" damit man nie unsichtbar landet.
|
// Dort ändern → wirkt für Reset (Tabelle) und diese Theme-Fallbacks gleichzeitig.
|
||||||
export const DEFAULT_TEXT_COLOR = '#FFFFFF';
|
export const DEFAULT_TEXT_COLOR = DEFAULT_COLORS.text_color;
|
||||||
export const DEFAULT_LINE_COLOR = '#3A3A52';
|
export const DEFAULT_LINE_COLOR = DEFAULT_COLORS.line_color;
|
||||||
export const DEFAULT_BG_COLOR = '#000000';
|
export const DEFAULT_BG_COLOR = DEFAULT_COLORS.bg_color;
|
||||||
|
|
||||||
export function applyTheme(settings) {
|
export function applyTheme(settings) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.style.setProperty('--primary', settings.primary_color || '#4285f4');
|
// Fallback chain for a colour that has no per-user value: admin instance
|
||||||
root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || '#4285f4', 0.15));
|
// default → built-in default (baseColor()).
|
||||||
root.style.setProperty('--accent', settings.accent_color || '#ea4335');
|
const primary = settings.primary_color || baseColor('primary_color');
|
||||||
root.style.setProperty('--today-color', settings.today_color || '#4285f4');
|
root.style.setProperty('--primary', primary);
|
||||||
|
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 > Default).
|
// Effektive Farben bestimmen (Override > Admin-Default > eingebauter Default).
|
||||||
let textColor = settings.text_color || DEFAULT_TEXT_COLOR;
|
let textColor = settings.text_color || baseColor('text_color');
|
||||||
let lineColor = settings.line_color || DEFAULT_LINE_COLOR;
|
let lineColor = settings.line_color || baseColor('line_color');
|
||||||
let bgColor = settings.bg_color || DEFAULT_BG_COLOR;
|
let bgColor = settings.bg_color || baseColor('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),
|
||||||
@@ -110,18 +136,73 @@ 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', shadeHex(bgColor, 0.10));
|
root.style.setProperty('--bg-topbar', surfaceBase);
|
||||||
root.style.setProperty('--bg-sidebar', shadeHex(bgColor, 0.10));
|
root.style.setProperty('--bg-sidebar', surfaceBase);
|
||||||
root.style.setProperty('--bg-surface', shadeHex(bgColor, 0.18));
|
root.style.setProperty('--bg-surface', shadeHex(surfaceBase, 0.10));
|
||||||
root.style.setProperty('--bg-hover', shadeHex(bgColor, 0.26));
|
root.style.setProperty('--bg-hover', shadeHex(surfaceBase, 0.20));
|
||||||
root.style.setProperty('--bg-active', shadeHex(bgColor, 0.40));
|
root.style.setProperty('--bg-active', shadeHex(surfaceBase, 0.34));
|
||||||
|
|
||||||
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 || '#7090c0');
|
root.style.setProperty('--month-divider-color', settings.month_divider_color || baseColor('month_divider_color'));
|
||||||
root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
|
root.style.setProperty('--month-label-color', settings.month_label_color || baseColor('month_label_color'));
|
||||||
|
|
||||||
|
// 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,2 +1,2 @@
|
|||||||
// Increment APP_VERSION with every code change
|
// Increment APP_VERSION with every code change
|
||||||
export const APP_VERSION = 'v62';
|
export const APP_VERSION = 'v95';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isPast } from '../utils.js';
|
import { isPast, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||||
import { t, getLang } from '../i18n.js';
|
import { t, getLocale } 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">${escHtml(ev.title)}</div>
|
<div class="agenda-ev-title">${ev.is_birthday ? birthdayIconSvg() : ''}${escHtml(eventTitle(ev))}</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(getLang(), { hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
|
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||||
import { t } from '../i18n.js';
|
import { t, getLocale } 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,14 +110,15 @@ 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(ev.title);
|
const titleEsc = escHtml(eventTitle(ev));
|
||||||
|
const icon = ev.is_birthday ? birthdayIconSvg() : '';
|
||||||
const labelHtml = ev.allDay
|
const labelHtml = ev.allDay
|
||||||
? titleEsc
|
? icon + titleEsc
|
||||||
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${titleEsc}`;
|
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${icon}${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(ev.title)}">${labelHtml}</div>`;
|
title="${escAttr(eventTitle(ev))}">${labelHtml}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// "+N more" per column
|
// "+N more" per column
|
||||||
@@ -250,7 +251,7 @@ function dateKey(d) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(d) {
|
function fmtTime(d) {
|
||||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
@@ -292,7 +293,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.textContent = ev.title;
|
bar.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev));
|
||||||
bar.addEventListener('click', e => {
|
bar.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
popup.remove();
|
popup.remove();
|
||||||
@@ -314,7 +315,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.textContent = ev.title;
|
title.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev));
|
||||||
|
|
||||||
row.append(dot, time, title);
|
row.append(dot, time, title);
|
||||||
row.addEventListener('click', e => {
|
row.addEventListener('click', e => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isToday, isPast, dayOfWeek } from '../utils.js';
|
import { isToday, isPast, dayOfWeek, eventTitle } 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(ev.title)}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`;
|
return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(eventTitle(ev))}" 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>`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
|
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||||
import { t } from '../i18n.js';
|
import { t, getLocale } 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,11 +77,12 @@ 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))} ${ev.title}`
|
? `${fmtTime(new Date(ev.start))} ${eventTitle(ev)}`
|
||||||
: ev.title;
|
: eventTitle(ev);
|
||||||
|
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(ev.title)}">${escHtml(label)}</div>`;
|
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}">${icon}${escHtml(label)}</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
const alldayBgCols = days.map(day =>
|
const alldayBgCols = days.map(day =>
|
||||||
@@ -129,11 +130,12 @@ 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(ev.title)}">
|
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}">
|
||||||
<div class="ev-time">${startStr}</div>
|
<div class="ev-time">${startStr}</div>
|
||||||
<div class="ev-title">${escHtml(ev.title)}</div>
|
<div class="ev-title">${icon}${escHtml(eventTitle(ev))}</div>
|
||||||
${locHtml}
|
${locHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -350,7 +352,7 @@ function isSameDay(a, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(d) {
|
function fmtTime(d) {
|
||||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
|
"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": "#4285f4",
|
"theme_color": "#16713d",
|
||||||
|
"categories": ["productivity", "utilities"],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-192.png",
|
"src": "/icons/icon-192.png",
|
||||||
@@ -21,10 +26,16 @@
|
|||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon.svg",
|
"src": "/icons/icon-maskable-192.png",
|
||||||
"sizes": "any",
|
"sizes": "192x192",
|
||||||
"type": "image/svg+xml",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-v23';
|
const CACHE_VERSION = 'calendarr-v41';
|
||||||
const OFFLINE_SHELL = ['/', '/index.html'];
|
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
@@ -34,6 +34,12 @@ 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/')) {
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ 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
|
||||||
|
|||||||