M4 Calendar: reminders form, invitations, short publish links, settings UX
Calendar settings (Settings > Calendars):
- Reminders are now {method, minutes}: Add-reminder opens a boxed form with a
method dropdown (Show an alert / Email me) + time dropdown, bell icons.
Backend normalizes the legacy list[int] transparently.
- New Invitations section (auto-add emailed events): policy radios, "Add new
events from" (My contacts / Anyone), "Add to calendar", mark-read+file toggle.
Persisted to user settings (inbound iMIP processing still deferred).
- Time zone options show GMT codes, e.g. "Central European Summer Time (GMT+2)".
- Default calendar now defaults to "Personal" instead of a generic placeholder.
- Colour picker is a circular-swatch-only dropdown (sized so swatches don't overlap).
- Delete calendar / remove subscription now confirm via a modal.
- Create form: dropped the breadcrumb; heading stays "Create calendar" even after
a Publish toggle auto-creates the calendar.
Publish links: replaced the 244-char vault-sealed token with a 16-char random
slug (secrets.token_urlsafe). New sdk.public_session() (privileged, non-RLS) lets
the public /public/{token} feed resolve the slug directly with explicit filters.
Settings shell: "<- Mail" is now a context-aware "<- Back" via ?from=; Calendar
opens Settings with from=/p/calendar so Back returns to the calendar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -8,9 +8,9 @@ is expanded at query time (see queries.py / recurrence.py). Read-only calendars
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -68,20 +68,10 @@ async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> Non
|
||||
|
||||
# --- calendars ---------------------------------------------------------------
|
||||
|
||||
def _make_publish_token(user_id: uuid.UUID, calendar_id: uuid.UUID) -> str:
|
||||
"""Vault-seal {user_id, calendar_id} so the public /public/{token} feed can open
|
||||
the right RLS session without exposing the ids or needing auth."""
|
||||
payload = json.dumps({"u": str(user_id), "c": str(calendar_id)}).encode()
|
||||
return base64.urlsafe_b64encode(_SDK.vault.encrypt(payload)).decode().rstrip("=")
|
||||
|
||||
|
||||
def _read_publish_token(token: str) -> tuple[uuid.UUID, uuid.UUID] | None:
|
||||
try:
|
||||
pad = "=" * (-len(token) % 4)
|
||||
data = json.loads(_SDK.vault.decrypt(base64.urlsafe_b64decode(token + pad)))
|
||||
return uuid.UUID(data["u"]), uuid.UUID(data["c"])
|
||||
except Exception:
|
||||
return None
|
||||
def _make_publish_token() -> str:
|
||||
"""A short, unguessable slug for the public /public/{token} .ics feed. Stored in
|
||||
plg_calendar_calendars.publish_token; the public route resolves it directly."""
|
||||
return secrets.token_urlsafe(12)
|
||||
|
||||
|
||||
def _calendar_ics(name: object, rows: list[dict[str, object]], *, busy_only: bool) -> bytes:
|
||||
@@ -199,7 +189,7 @@ async def patch_calendar(
|
||||
{"id": str(calendar_id)},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
token = existing or _make_publish_token(user_id, calendar_id)
|
||||
token = existing or _make_publish_token()
|
||||
set_parts += ["publish_token = :ptok", "publish_busy = :pbusy"]
|
||||
params["ptok"] = token
|
||||
params["pbusy"] = publish == "busy"
|
||||
@@ -700,23 +690,21 @@ async def delete_event(
|
||||
|
||||
@router.get("/public/{token}")
|
||||
async def public_calendar(token: str) -> Response:
|
||||
parsed = _read_publish_token(token)
|
||||
if parsed is None:
|
||||
raise HTTPException(status_code=404, detail="not_found")
|
||||
user_id, cal_id = parsed
|
||||
async with _SDK.session(user_id) as s:
|
||||
# Public + unauthenticated: resolve the opaque slug directly (privileged session,
|
||||
# every query filtered explicitly by the token / its calendar id — no RLS).
|
||||
async with _SDK.public_session() as s:
|
||||
row = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT name, publish_token, publish_busy "
|
||||
"FROM plg_calendar_calendars WHERE id = :id"
|
||||
"SELECT id, name, publish_busy FROM plg_calendar_calendars "
|
||||
"WHERE publish_token = :tok"
|
||||
),
|
||||
{"id": str(cal_id)},
|
||||
{"tok": token},
|
||||
)
|
||||
).mappings().first()
|
||||
if row is None or row["publish_token"] is None:
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="not_found")
|
||||
rows = await _calendar_event_rows(s, cal_id)
|
||||
rows = await _calendar_event_rows(s, row["id"])
|
||||
return Response(
|
||||
content=_calendar_ics(row["name"], rows, busy_only=row["publish_busy"]),
|
||||
media_type="text/calendar",
|
||||
|
||||
@@ -1199,8 +1199,9 @@ export default function mount(el, ctx) {
|
||||
state.menuOpen = false;
|
||||
if (a.to) ctx.navigate(a.to); else render();
|
||||
});
|
||||
act("settings", () => ctx.navigate("/settings"));
|
||||
act("add-calendar", () => ctx.navigate("/settings/calendars?create=1"));
|
||||
// Pass ?from=/p/calendar so Settings' back button returns here, not to Mail.
|
||||
act("settings", () => ctx.navigate("/settings?from=%2Fp%2Fcalendar"));
|
||||
act("add-calendar", () => ctx.navigate("/settings/calendars?from=%2Fp%2Fcalendar&create=1"));
|
||||
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
|
||||
act("new", () => openForm(null));
|
||||
act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
|
||||
@@ -1373,8 +1374,11 @@ export default function mount(el, ctx) {
|
||||
HOUR12 = s && s.time_format === "12"; // 24h is the default
|
||||
state.jitsi = (s && s.jitsi_server) || "";
|
||||
state.defaultCalendar = (s && s.default_calendar) || "";
|
||||
state.defaultRemTimed = (s && s.default_reminders_timed) || [];
|
||||
state.defaultRemAllday = (s && s.default_reminders_allday) || [];
|
||||
// Reminders are stored as {method, minutes}; event reminders only use minutes.
|
||||
const remMins = (arr) =>
|
||||
(arr || []).map((x) => (typeof x === "number" ? x : (x && x.minutes) || 0));
|
||||
state.defaultRemTimed = remMins(s && s.default_reminders_timed);
|
||||
state.defaultRemAllday = remMins(s && s.default_reminders_allday);
|
||||
} catch { /* keep defaults (24h, public Jitsi) */ }
|
||||
try {
|
||||
const q = await api.fetch("/settings/quota");
|
||||
|
||||
Reference in New Issue
Block a user