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:
2026-08-02 08:48:33 +02:00
co-authored by Claude Opus 4.8
parent e2bef3c41d
commit 57a1f1562f
10 changed files with 447 additions and 113 deletions
+58 -10
View File
@@ -19,6 +19,14 @@ DbSession = Annotated[AsyncSession, Depends(get_session)]
_ALLOWED_ORDER = {"asc", "desc"} _ALLOWED_ORDER = {"asc", "desc"}
_ALLOWED_TIME_FORMAT = {"12", "24"} _ALLOWED_TIME_FORMAT = {"12", "24"}
_ALLOWED_REMINDER_METHOD = {"alert", "email"}
_ALLOWED_INVITE_POLICY = {"add_update", "update_only", "none"}
_ALLOWED_INVITE_FROM = {"contacts", "anyone"}
class ReminderPref(BaseModel):
method: str = "alert" # alert = in-app notification, email = email me
minutes: int = 0 # minutes before the event start (0 = at time of event)
class SettingsOut(BaseModel): class SettingsOut(BaseModel):
@@ -27,8 +35,12 @@ class SettingsOut(BaseModel):
time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide
jitsi_server: str = "" # blank => https://meet.jit.si/ jitsi_server: str = "" # blank => https://meet.jit.si/
default_calendar: str = "" # calendar id new events default to (blank => first) default_calendar: str = "" # calendar id new events default to (blank => first)
default_reminders_timed: list[int] = [] # minutes-before defaults for timed events default_reminders_timed: list[ReminderPref] = [] # defaults for timed events
default_reminders_allday: list[int] = [] # minutes-before defaults for all-day events default_reminders_allday: list[ReminderPref] = [] # defaults for all-day events
invitation_policy: str = "add_update" # how emailed events update the calendar
invitation_from: str = "contacts" # whose emailed events auto-add (contacts|anyone)
invitation_calendar: str = "" # calendar id emailed events land in (blank => default)
invitation_file: bool = False # also mark the invite message read + filed
allowed_image_senders: list[str] = [] allowed_image_senders: list[str] = []
@@ -38,8 +50,12 @@ class SettingsPatch(BaseModel):
time_format: str | None = None time_format: str | None = None
jitsi_server: str | None = None jitsi_server: str | None = None
default_calendar: str | None = None default_calendar: str | None = None
default_reminders_timed: list[int] | None = None default_reminders_timed: list[ReminderPref] | None = None
default_reminders_allday: list[int] | None = None default_reminders_allday: list[ReminderPref] | None = None
invitation_policy: str | None = None
invitation_from: str | None = None
invitation_calendar: str | None = None
invitation_file: bool | None = None
class QuotaOut(BaseModel): class QuotaOut(BaseModel):
@@ -62,8 +78,24 @@ def add_allowed_image_sender(user: User, email: str) -> None:
user.settings = {**user.settings, "allowed_image_senders": current} user.settings = {**user.settings, "allowed_image_senders": current}
def _int_list(value: object) -> list[int]: def _reminder_list(value: object) -> list[ReminderPref]:
return [int(x) for x in value] if isinstance(value, list) else [] """Normalize stored reminders. Accepts the legacy ``list[int]`` (minutes only,
method defaults to alert) as well as the ``{method, minutes}`` object form."""
if not isinstance(value, list):
return []
out: list[ReminderPref] = []
for x in value:
if isinstance(x, dict):
method = str(x.get("method", "alert"))
out.append(
ReminderPref(
method=method if method in _ALLOWED_REMINDER_METHOD else "alert",
minutes=int(x.get("minutes", 0)),
)
)
else:
out.append(ReminderPref(method="alert", minutes=int(x)))
return out
def _settings_out(user: User) -> SettingsOut: def _settings_out(user: User) -> SettingsOut:
@@ -74,8 +106,12 @@ def _settings_out(user: User) -> SettingsOut:
time_format=str(s.get("time_format", "24")), time_format=str(s.get("time_format", "24")),
jitsi_server=str(s.get("jitsi_server", "")), jitsi_server=str(s.get("jitsi_server", "")),
default_calendar=str(s.get("default_calendar", "")), default_calendar=str(s.get("default_calendar", "")),
default_reminders_timed=_int_list(s.get("default_reminders_timed")), default_reminders_timed=_reminder_list(s.get("default_reminders_timed")),
default_reminders_allday=_int_list(s.get("default_reminders_allday")), default_reminders_allday=_reminder_list(s.get("default_reminders_allday")),
invitation_policy=str(s.get("invitation_policy", "add_update")),
invitation_from=str(s.get("invitation_from", "contacts")),
invitation_calendar=str(s.get("invitation_calendar", "")),
invitation_file=bool(s.get("invitation_file", False)),
allowed_image_senders=sorted(allowed_image_senders(user)), allowed_image_senders=sorted(allowed_image_senders(user)),
) )
@@ -108,9 +144,21 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
if body.default_calendar is not None: if body.default_calendar is not None:
updates["default_calendar"] = body.default_calendar updates["default_calendar"] = body.default_calendar
if body.default_reminders_timed is not None: if body.default_reminders_timed is not None:
updates["default_reminders_timed"] = [int(x) for x in body.default_reminders_timed] updates["default_reminders_timed"] = [
r.model_dump() for r in body.default_reminders_timed
]
if body.default_reminders_allday is not None: if body.default_reminders_allday is not None:
updates["default_reminders_allday"] = [int(x) for x in body.default_reminders_allday] updates["default_reminders_allday"] = [
r.model_dump() for r in body.default_reminders_allday
]
if body.invitation_policy is not None and body.invitation_policy in _ALLOWED_INVITE_POLICY:
updates["invitation_policy"] = body.invitation_policy
if body.invitation_from is not None and body.invitation_from in _ALLOWED_INVITE_FROM:
updates["invitation_from"] = body.invitation_from
if body.invitation_calendar is not None:
updates["invitation_calendar"] = body.invitation_calendar
if body.invitation_file is not None:
updates["invitation_file"] = body.invitation_file
user.settings = updates user.settings = updates
await db.commit() await db.commit()
return _settings_out(user) return _settings_out(user)
+11
View File
@@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.ssrf import guarded_get from app.accounts.ssrf import guarded_get
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.db import session_scope
from app.events import get_event_bus from app.events import get_event_bus
from app.identity.deps import current_user, require_csrf from app.identity.deps import current_user, require_csrf
from app.indexer import blobstore from app.indexer import blobstore
@@ -95,6 +96,16 @@ class PluginSdk:
across users (see :mod:`app.plugins.db`).""" across users (see :mod:`app.plugins.db`)."""
return plugin_session(user_id) return plugin_session(user_id)
def public_session(self) -> AbstractAsyncContextManager[AsyncSession]:
"""A privileged, NON-RLS session for PUBLIC, unauthenticated endpoints that
resolve their own opaque token and therefore must look up rows before any
user is known (e.g. a public ``.ics`` feed keyed by an unguessable slug).
There is no row-level safety net here: EVERY query MUST filter explicitly by
the token (or an id derived from it). Never use this for authenticated
handlers — use :meth:`session` so RLS scopes queries to the user."""
return session_scope()
def kv(self, user_id: uuid.UUID) -> PluginKvStore: def kv(self, user_id: uuid.UUID) -> PluginKvStore:
"""The namespaced KV store for this plugin and ``user_id`` (SPEC §11).""" """The namespaced KV store for this plugin and ``user_id`` (SPEC §11)."""
return PluginKvStore(self.name, user_id) return PluginKvStore(self.name, user_id)
+18
View File
@@ -38,6 +38,7 @@ export const en = {
"auth.register": "Register", "auth.register": "Register",
"nav.mail": "Mail", "nav.mail": "Mail",
"nav.back": "Back",
"nav.settings": "Settings", "nav.settings": "Settings",
"nav.compose": "Compose", "nav.compose": "Compose",
"nav.collapseSidebar": "Collapse sidebar", "nav.collapseSidebar": "Collapse sidebar",
@@ -254,6 +255,23 @@ export const en = {
"settings.subscribed": "Subscribed", "settings.subscribed": "Subscribed",
"settings.comingSoon": "Coming soon", "settings.comingSoon": "Coming soon",
"settings.subscribeError": "Couldn't subscribe to that URL.", "settings.subscribeError": "Couldn't subscribe to that URL.",
"settings.removeSubscription": "Remove subscription",
"settings.removeSubscriptionConfirm": "Stop syncing “{name}” and remove its events?",
"settings.deleteCalendar": "Delete calendar",
"settings.thisCalendar": "this calendar",
"settings.deleteCalendarConfirm": "Delete “{name}” and all of its events? This can't be undone.",
"settings.showAlert": "Show an alert",
"settings.emailMe": "Email me",
"settings.invitations": "Invitations",
"settings.inviteWhen": "When I receive events by email",
"settings.inviteAddUpdate": "Add new events to my calendar and update events that have changed",
"settings.inviteUpdateOnly": "Only update events that I have already added to my calendar",
"settings.inviteNone": "Do not automatically update my calendar",
"settings.addNewFrom": "Add new events from",
"settings.myContacts": "My contacts",
"settings.anyone": "Anyone",
"settings.addToCalendar": "Add to calendar",
"settings.alsoFile": "Also mark the message as read and file it",
"settings.integrations": "Integrations", "settings.integrations": "Integrations",
"settings.jitsi": "Jitsi Meet", "settings.jitsi": "Jitsi Meet",
"settings.jitsiHint": "settings.jitsiHint":
+4
View File
@@ -178,6 +178,10 @@ export const api = {
| "default_calendar" | "default_calendar"
| "default_reminders_timed" | "default_reminders_timed"
| "default_reminders_allday" | "default_reminders_allday"
| "invitation_policy"
| "invitation_from"
| "invitation_calendar"
| "invitation_file"
> >
>, >,
) => ) =>
+11 -2
View File
@@ -159,14 +159,23 @@ export interface CalendarOut {
subscription_url: string | null; subscription_url: string | null;
} }
export interface ReminderPref {
method: "alert" | "email";
minutes: number;
}
export interface UserSettings { export interface UserSettings {
thread_order: "asc" | "desc"; thread_order: "asc" | "desc";
tz: string; tz: string;
time_format: "12" | "24"; time_format: "12" | "24";
jitsi_server: string; jitsi_server: string;
default_calendar: string; default_calendar: string;
default_reminders_timed: number[]; default_reminders_timed: ReminderPref[];
default_reminders_allday: number[]; default_reminders_allday: ReminderPref[];
invitation_policy: "add_update" | "update_only" | "none";
invitation_from: "contacts" | "anyone";
invitation_calendar: string;
invitation_file: boolean;
allowed_image_senders: string[]; allowed_image_senders: string[];
} }
+323 -71
View File
@@ -6,7 +6,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"
import { RichTextEditor } from "../../components/compose/RichTextEditor"; import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import type { CalendarOut } from "../../lib/types"; import type { CalendarOut, ReminderPref } from "../../lib/types";
import { useMe } from "../../lib/auth"; import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme"; import { useTheme } from "../../lib/theme";
import { setTimeFormat } from "../../lib/timeFormat"; import { setTimeFormat } from "../../lib/timeFormat";
@@ -18,13 +18,19 @@ type Section = (typeof SECTIONS)[number];
export function Settings() { export function Settings() {
const t = useT(); const t = useT();
const params = useParams(); const params = useParams();
const [searchParams] = useSearchParams();
// Where the back button returns to — set by whoever opened Settings (Mail or a
// plugin like Calendar via ?from=). Only accept internal paths; default to Mail.
const rawFrom = searchParams.get("from") ?? "";
const from = rawFrom.startsWith("/") ? rawFrom : "/mail";
const fromQuery = `?from=${encodeURIComponent(from)}`;
const section = (SECTIONS.includes(params.section as Section) ? params.section : "profile") as Section; const section = (SECTIONS.includes(params.section as Section) ? params.section : "profile") as Section;
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2"> <header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
<Link to="/mail" className="text-sm text-app-accent"> <Link to={from} className="text-sm text-app-accent">
← {t("nav.mail")} ← {t("nav.back")}
</Link> </Link>
<span className="font-semibold">{t("settings.title")}</span> <span className="font-semibold">{t("settings.title")}</span>
</header> </header>
@@ -33,7 +39,7 @@ export function Settings() {
{SECTIONS.map((s) => ( {SECTIONS.map((s) => (
<Link <Link
key={s} key={s}
to={`/settings/${s}`} to={`/settings/${s}${fromQuery}`}
className={`block rounded px-3 py-2 text-sm ${ className={`block rounded px-3 py-2 text-sm ${
section === s ? "bg-app-active font-medium text-app-fg" : "hover:bg-app-hover" section === s ? "bg-app-active font-medium text-app-fg" : "hover:bg-app-hover"
}`} }`}
@@ -662,6 +668,117 @@ const CAL_COLORS: [string, string][] = [
["Gray", "#6b7280"], ["Gray", "#6b7280"],
]; ];
function BellIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
</svg>
);
}
// A compact colour picker showing circular swatches only (no text).
function ColorSelect({ value, onChange }: { value: string; onChange: (hex: string) => void }) {
const [open, setOpen] = useState(false);
const palette: [string, string][] = CAL_COLORS.some(([, hex]) => hex === value)
? CAL_COLORS
: [["Custom", value], ...CAL_COLORS];
return (
<div className="relative inline-block">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-2 rounded border border-app-border bg-app-surface px-2.5 py-2"
aria-label="Select colour"
>
<span className="inline-block h-5 w-5 rounded-full" style={{ background: value }} />
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4 text-app-muted">
<path
fillRule="evenodd"
d="M5.2 7.5 10 12.3l4.8-4.8 1.2 1.2-6 6-6-6z"
clipRule="evenodd"
/>
</svg>
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute left-0 z-20 mt-1 grid w-48 grid-cols-4 gap-3 rounded-lg border border-app-border bg-app-surface p-3 shadow-lg">
{palette.map(([name, hex]) => (
<button
key={hex}
type="button"
title={name}
onClick={() => {
onChange(hex);
setOpen(false);
}}
className={`h-6 w-6 justify-self-center rounded-full ring-offset-2 ring-offset-app-surface ${
value === hex ? "ring-2 ring-app-fg" : ""
}`}
style={{ background: hex }}
/>
))}
</div>
</>
)}
</div>
);
}
function ConfirmDialog({
title,
message,
confirmLabel,
onConfirm,
onCancel,
}: {
title: string;
message: string;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
}) {
const t = useT();
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onCancel}
>
<div
className="w-full max-w-sm rounded-lg border border-app-border bg-app-surface p-5 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold">{title}</h3>
<p className="mt-2 text-sm text-app-muted">{message}</p>
<div className="mt-5 flex justify-end gap-2">
<button
className="rounded bg-app-hover px-4 py-1.5 text-sm hover:opacity-90"
onClick={onCancel}
>
{t("common.cancel")}
</button>
<button
className="rounded bg-red-500 px-4 py-1.5 text-sm font-medium text-white hover:opacity-90"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
function SettingsToggle({ function SettingsToggle({
checked, checked,
onChange, onChange,
@@ -699,9 +816,41 @@ const TZ_RAW: string[] = (() => {
return []; return [];
} }
})(); })();
const TZ_LIST = TZ_RAW.length const TZ_ZONES = TZ_RAW.length
? TZ_RAW ? TZ_RAW
: ["UTC", "Europe/London", "Europe/Warsaw", "America/New_York", "Asia/Tokyo"]; : ["UTC", "Europe/London", "Europe/Warsaw", "America/New_York", "Asia/Tokyo"];
// The GMT offset code (e.g. "GMT+2") for a zone, as shown next to its name.
function tzGmt(zone: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
timeZoneName: "shortOffset",
}).formatToParts(new Date());
return parts.find((p) => p.type === "timeZoneName")?.value ?? "";
} catch {
return "";
}
}
function tzLongName(zone: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
timeZoneName: "long",
}).formatToParts(new Date());
return parts.find((p) => p.type === "timeZoneName")?.value ?? zone;
} catch {
return zone;
}
}
// [zone, "Long Name (GMT+2)"] sorted by offset then label, computed once.
const TZ_LIST: [string, string][] = TZ_ZONES.map((z): [string, string] => {
const gmt = tzGmt(z);
return [z, gmt ? `${tzLongName(z)} (${gmt})` : tzLongName(z)];
}).sort((a, b) => a[1].localeCompare(b[1]));
const REMINDER_OPTS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880]; const REMINDER_OPTS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880];
function reminderText(m: number): string { function reminderText(m: number): string {
@@ -772,6 +921,11 @@ function CalendarList({
queryClient.invalidateQueries({ queryKey: ["calendars"] }); queryClient.invalidateQueries({ queryKey: ["calendars"] });
}, },
}); });
const [removeSub, setRemoveSub] = useState<CalendarOut | null>(null);
const removeCal = useMutation({
mutationFn: (id: string) => api.deleteCalendar(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["calendars"] }),
});
const subscriptions = calendars.filter((c) => c.subscription_url !== null); const subscriptions = calendars.filter((c) => c.subscription_url !== null);
const accounts: [string, string][] = [ const accounts: [string, string][] = [
["Google", "google"], ["Google", "google"],
@@ -856,11 +1010,7 @@ function CalendarList({
</div> </div>
<button <button
className="rounded px-3 py-1 text-sm text-red-500 hover:bg-app-hover" className="rounded px-3 py-1 text-sm text-red-500 hover:bg-app-hover"
onClick={() => { onClick={() => setRemoveSub(c)}
void api.deleteCalendar(c.id).then(() =>
queryClient.invalidateQueries({ queryKey: ["calendars"] }),
);
}}
> >
{t("common.remove")} {t("common.remove")}
</button> </button>
@@ -888,6 +1038,19 @@ function CalendarList({
<p className="mt-2 text-sm text-red-500">{t("settings.subscribeError")}</p> <p className="mt-2 text-sm text-red-500">{t("settings.subscribeError")}</p>
)} )}
</div> </div>
{removeSub && (
<ConfirmDialog
title={t("settings.removeSubscription")}
message={t("settings.removeSubscriptionConfirm", { name: removeSub.name })}
confirmLabel={t("common.remove")}
onCancel={() => setRemoveSub(null)}
onConfirm={() => {
removeCal.mutate(removeSub.id);
setRemoveSub(null);
}}
/>
)}
</div> </div>
); );
} }
@@ -896,7 +1059,9 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa
const t = useT(); const t = useT();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [calId, setCalId] = useState<string | null>(calendar?.id ?? null); const [calId, setCalId] = useState<string | null>(calendar?.id ?? null);
const editing = calId !== null; // Whether we opened an existing calendar to edit — fixed for the form's lifetime, so
// auto-creating on a Publish toggle doesn't flip the "Create calendar" heading.
const startedNew = calendar === null;
const [name, setName] = useState(calendar?.name ?? ""); const [name, setName] = useState(calendar?.name ?? "");
const [color, setColor] = useState(calendar?.color ?? CAL_COLORS[0][1]); const [color, setColor] = useState(calendar?.color ?? CAL_COLORS[0][1]);
const [publishToken, setPublishToken] = useState<string | null>(calendar?.publish_token ?? null); const [publishToken, setPublishToken] = useState<string | null>(calendar?.publish_token ?? null);
@@ -916,6 +1081,7 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa
onSuccess: done, onSuccess: done,
}); });
const del = useMutation({ mutationFn: () => api.deleteCalendar(calId!), onSuccess: done }); const del = useMutation({ mutationFn: () => api.deleteCalendar(calId!), onSuccess: done });
const [confirmDel, setConfirmDel] = useState(false);
// Turning on a Publish option auto-creates the calendar first (create mode) so a // Turning on a Publish option auto-creates the calendar first (create mode) so a
// shareable .ics link can be shown immediately, then flips the public feed on/off. // shareable .ics link can be shown immediately, then flips the public feed on/off.
@@ -957,43 +1123,16 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa
</div> </div>
); );
const colorOptions: [string, string][] = CAL_COLORS.some(([, hex]) => hex === color)
? CAL_COLORS
: [["Custom", color], ...CAL_COLORS];
return ( return (
<div> <div>
<div className="mb-5 flex items-center gap-2 border-b border-app-border pb-3 text-sm text-app-muted">
<button className="text-app-accent hover:underline" onClick={onBack}>
{t("settings.myCalendars")}
</button>
<span>›</span>
<span className="text-app-fg">
{editing ? t("settings.editCalendar") : t("settings.createCalendar")}
</span>
</div>
<div className="grid grid-cols-[160px_1fr] gap-x-8 gap-y-6 border-b border-app-border pb-6"> <div className="grid grid-cols-[160px_1fr] gap-x-8 gap-y-6 border-b border-app-border pb-6">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{editing ? t("settings.editCalendar") : t("settings.createCalendar")} {startedNew ? t("settings.createCalendar") : t("settings.editCalendar")}
</h3> </h3>
<div className="flex items-end gap-4"> <div className="flex items-end gap-4">
<div> <div>
<span className="mb-1 block text-sm font-medium">{t("settings.color")}</span> <span className="mb-1 block text-sm font-medium">{t("settings.color")}</span>
<div className="flex items-center gap-2 rounded border border-app-border bg-app-surface px-2 py-1.5"> <ColorSelect value={color} onChange={setColor} />
<span className="inline-block h-4 w-4 rounded-full" style={{ background: color }} />
<select
className="bg-app-surface text-sm text-app-fg outline-none"
value={color}
onChange={(e) => setColor(e.target.value)}
>
{colorOptions.map(([label, hex]) => (
<option key={hex} value={hex} className="bg-app-surface text-app-fg">
{label}
</option>
))}
</select>
</div>
</div> </div>
<label className="block flex-1"> <label className="block flex-1">
<span className="mb-1 block text-sm font-medium">{t("settings.name")}</span> <span className="mb-1 block text-sm font-medium">{t("settings.name")}</span>
@@ -1049,48 +1188,81 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa
<button className="rounded bg-app-hover px-4 py-1.5 text-sm hover:opacity-90" onClick={onBack}> <button className="rounded bg-app-hover px-4 py-1.5 text-sm hover:opacity-90" onClick={onBack}>
{t("common.cancel")} {t("common.cancel")}
</button> </button>
{editing && ( {!startedNew && (
<button <button
className="ml-auto rounded px-4 py-1.5 text-sm text-red-500 hover:bg-app-hover" className="ml-auto rounded px-4 py-1.5 text-sm text-red-500 hover:bg-app-hover"
onClick={() => del.mutate()} onClick={() => setConfirmDel(true)}
> >
{t("common.delete")} {t("common.delete")}
</button> </button>
)} )}
</div> </div>
{confirmDel && (
<ConfirmDialog
title={t("settings.deleteCalendar")}
message={t("settings.deleteCalendarConfirm", { name: name || t("settings.thisCalendar") })}
confirmLabel={t("common.delete")}
onCancel={() => setConfirmDel(false)}
onConfirm={() => {
setConfirmDel(false);
del.mutate();
}}
/>
)}
</div> </div>
); );
} }
const REM_SELECT_CLASS =
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm text-app-fg outline-none focus:border-app-accent";
function ReminderList({ function ReminderList({
label, label,
value, value,
onChange, onChange,
}: { }: {
label: string; label: string;
value: number[]; value: ReminderPref[];
onChange: (v: number[]) => void; onChange: (v: ReminderPref[]) => void;
}) { }) {
const t = useT(); const t = useT();
const update = (i: number, patch: Partial<ReminderPref>) =>
onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
return ( return (
<div> <div>
<span className="mb-2 block text-sm font-medium">{label}</span> <span className="mb-2 block text-sm font-medium">{label}</span>
<div className="space-y-2"> <div className="space-y-3">
{value.map((m, i) => ( {value.map((r, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-start gap-2">
<select <BellIcon className="mt-2.5 h-4 w-4 shrink-0 text-app-muted" />
className="rounded border border-app-border bg-app-surface px-2 py-1 text-sm text-app-fg outline-none" <div className="flex-1 space-y-2 rounded-lg border border-app-border p-2">
value={m} <select
onChange={(e) => onChange(value.map((x, idx) => (idx === i ? Number(e.target.value) : x)))} className={REM_SELECT_CLASS}
> value={r.method}
{REMINDER_OPTS.map((o) => ( onChange={(e) => update(i, { method: e.target.value as ReminderPref["method"] })}
<option key={o} value={o} className="bg-app-surface text-app-fg"> >
{reminderText(o)} <option value="alert" className="bg-app-surface text-app-fg">
{t("settings.showAlert")}
</option> </option>
))} <option value="email" className="bg-app-surface text-app-fg">
</select> {t("settings.emailMe")}
</option>
</select>
<select
className={`${REM_SELECT_CLASS} border-app-accent`}
value={r.minutes}
onChange={(e) => update(i, { minutes: Number(e.target.value) })}
>
{REMINDER_OPTS.map((o) => (
<option key={o} value={o} className="bg-app-surface text-app-fg">
{reminderText(o)}
</option>
))}
</select>
</div>
<button <button
className="px-1 text-app-muted hover:text-app-fg" className="mt-1.5 px-1 text-app-muted hover:text-app-fg"
onClick={() => onChange(value.filter((_, idx) => idx !== i))} onClick={() => onChange(value.filter((_, idx) => idx !== i))}
aria-label={t("common.delete")} aria-label={t("common.delete")}
> >
@@ -1098,13 +1270,13 @@ function ReminderList({
</button> </button>
</div> </div>
))} ))}
<button
className="rounded bg-app-hover px-3 py-1.5 text-sm hover:opacity-90"
onClick={() => onChange([...value, 30])}
>
🔔 {t("settings.addReminder")}
</button>
</div> </div>
<button
className="mt-3 inline-flex items-center gap-2 rounded bg-app-hover px-3 py-1.5 text-sm hover:opacity-90"
onClick={() => onChange([...value, { method: "alert", minutes: 60 }])}
>
<BellIcon className="h-4 w-4" /> {t("settings.addReminder")}
</button>
</div> </div>
); );
} }
@@ -1120,6 +1292,15 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
const s = settings.data; const s = settings.data;
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
// When no default is set, fall back to "Personal" (the bootstrapped calendar) so the
// dropdown shows Personal rather than a generic placeholder; else the first calendar.
const preferredDefault =
calendars.find((c) => c.name === "Personal") ?? calendars[0];
const defaultCalValue =
s?.default_calendar && calendars.some((c) => c.id === s.default_calendar)
? s.default_calendar
: (preferredDefault?.id ?? "");
// Detect and persist the browser time zone on first visit (backend default is UTC). // Detect and persist the browser time zone on first visit (backend default is UTC).
useEffect(() => { useEffect(() => {
if (s && (!s.tz || s.tz === "UTC") && browserTz !== "UTC") patch.mutate({ tz: browserTz }); if (s && (!s.tz || s.tz === "UTC") && browserTz !== "UTC") patch.mutate({ tz: browserTz });
@@ -1137,9 +1318,9 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
value={s?.tz && s.tz !== "UTC" ? s.tz : browserTz} value={s?.tz && s.tz !== "UTC" ? s.tz : browserTz}
onChange={(e) => patch.mutate({ tz: e.target.value })} onChange={(e) => patch.mutate({ tz: e.target.value })}
> >
{TZ_LIST.map((z) => ( {TZ_LIST.map(([zone, tzLabel]) => (
<option key={z} value={z} className="bg-app-surface text-app-fg"> <option key={zone} value={zone} className="bg-app-surface text-app-fg">
{z} {tzLabel}
</option> </option>
))} ))}
</select> </select>
@@ -1153,12 +1334,9 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
<span className="mb-1 block text-sm font-medium">{t("settings.defaultCalendar")}</span> <span className="mb-1 block text-sm font-medium">{t("settings.defaultCalendar")}</span>
<select <select
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none" className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none"
value={s?.default_calendar ?? ""} value={defaultCalValue}
onChange={(e) => patch.mutate({ default_calendar: e.target.value })} onChange={(e) => patch.mutate({ default_calendar: e.target.value })}
> >
<option value="" className="bg-app-surface text-app-fg">
{t("settings.firstCalendar")}
</option>
{calendars.map((c) => ( {calendars.map((c) => (
<option key={c.id} value={c.id} className="bg-app-surface text-app-fg"> <option key={c.id} value={c.id} className="bg-app-surface text-app-fg">
{c.name} {c.name}
@@ -1178,6 +1356,80 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-[160px_1fr] gap-x-8">
<h3 className="text-base font-semibold">{t("settings.invitations")}</h3>
<div className="max-w-2xl space-y-5">
<fieldset>
<legend className="mb-2 text-sm font-medium">{t("settings.inviteWhen")}</legend>
<div className="space-y-2">
{(
[
["add_update", t("settings.inviteAddUpdate")],
["update_only", t("settings.inviteUpdateOnly")],
["none", t("settings.inviteNone")],
] as const
).map(([val, txt]) => (
<label key={val} className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="radio"
name="invitation_policy"
className="accent-app-accent"
checked={(s?.invitation_policy ?? "add_update") === val}
onChange={() => patch.mutate({ invitation_policy: val })}
/>
{txt}
</label>
))}
</div>
</fieldset>
<div className="flex flex-wrap gap-4">
<label className="block min-w-[200px] flex-1">
<span className="mb-1 block text-sm font-medium">{t("settings.addNewFrom")}</span>
<select
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none"
value={s?.invitation_from ?? "contacts"}
onChange={(e) =>
patch.mutate({ invitation_from: e.target.value as "contacts" | "anyone" })
}
>
<option value="contacts" className="bg-app-surface text-app-fg">
{t("settings.myContacts")}
</option>
<option value="anyone" className="bg-app-surface text-app-fg">
{t("settings.anyone")}
</option>
</select>
</label>
<label className="block min-w-[200px] flex-1">
<span className="mb-1 block text-sm font-medium">{t("settings.addToCalendar")}</span>
<select
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none"
value={s?.invitation_calendar ?? ""}
onChange={(e) => patch.mutate({ invitation_calendar: e.target.value })}
>
<option value="" className="bg-app-surface text-app-fg">
{t("settings.firstCalendar")}
</option>
{calendars.map((c) => (
<option key={c.id} value={c.id} className="bg-app-surface text-app-fg">
{c.name}
</option>
))}
</select>
</label>
</div>
<div className="flex max-w-md items-center justify-between">
<span className="text-sm font-medium">{t("settings.alsoFile")}</span>
<SettingsToggle
checked={s?.invitation_file ?? false}
onChange={(v) => patch.mutate({ invitation_file: v })}
/>
</div>
</div>
</div>
</div> </div>
); );
} }
@@ -8,9 +8,9 @@ is expanded at query time (see queries.py / recurrence.py). Read-only calendars
from __future__ import annotations from __future__ import annotations
import base64
import json import json
import logging import logging
import secrets
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -68,20 +68,10 @@ async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> Non
# --- calendars --------------------------------------------------------------- # --- calendars ---------------------------------------------------------------
def _make_publish_token(user_id: uuid.UUID, calendar_id: uuid.UUID) -> str: def _make_publish_token() -> str:
"""Vault-seal {user_id, calendar_id} so the public /public/{token} feed can open """A short, unguessable slug for the public /public/{token} .ics feed. Stored in
the right RLS session without exposing the ids or needing auth.""" plg_calendar_calendars.publish_token; the public route resolves it directly."""
payload = json.dumps({"u": str(user_id), "c": str(calendar_id)}).encode() return secrets.token_urlsafe(12)
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 _calendar_ics(name: object, rows: list[dict[str, object]], *, busy_only: bool) -> bytes: 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)}, {"id": str(calendar_id)},
) )
).scalar_one_or_none() ).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"] set_parts += ["publish_token = :ptok", "publish_busy = :pbusy"]
params["ptok"] = token params["ptok"] = token
params["pbusy"] = publish == "busy" params["pbusy"] = publish == "busy"
@@ -700,23 +690,21 @@ async def delete_event(
@router.get("/public/{token}") @router.get("/public/{token}")
async def public_calendar(token: str) -> Response: async def public_calendar(token: str) -> Response:
parsed = _read_publish_token(token) # Public + unauthenticated: resolve the opaque slug directly (privileged session,
if parsed is None: # every query filtered explicitly by the token / its calendar id — no RLS).
raise HTTPException(status_code=404, detail="not_found") async with _SDK.public_session() as s:
user_id, cal_id = parsed
async with _SDK.session(user_id) as s:
row = ( row = (
await s.execute( await s.execute(
text( text(
"SELECT name, publish_token, publish_busy " "SELECT id, name, publish_busy FROM plg_calendar_calendars "
"FROM plg_calendar_calendars WHERE id = :id" "WHERE publish_token = :tok"
), ),
{"id": str(cal_id)}, {"tok": token},
) )
).mappings().first() ).mappings().first()
if row is None or row["publish_token"] is None: if row is None:
raise HTTPException(status_code=404, detail="not_found") 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( return Response(
content=_calendar_ics(row["name"], rows, busy_only=row["publish_busy"]), content=_calendar_ics(row["name"], rows, busy_only=row["publish_busy"]),
media_type="text/calendar", media_type="text/calendar",
@@ -1199,8 +1199,9 @@ export default function mount(el, ctx) {
state.menuOpen = false; state.menuOpen = false;
if (a.to) ctx.navigate(a.to); else render(); if (a.to) ctx.navigate(a.to); else render();
}); });
act("settings", () => ctx.navigate("/settings")); // Pass ?from=/p/calendar so Settings' back button returns here, not to Mail.
act("add-calendar", () => ctx.navigate("/settings/calendars?create=1")); 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("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
act("new", () => openForm(null)); act("new", () => openForm(null));
act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); }); 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 HOUR12 = s && s.time_format === "12"; // 24h is the default
state.jitsi = (s && s.jitsi_server) || ""; state.jitsi = (s && s.jitsi_server) || "";
state.defaultCalendar = (s && s.default_calendar) || ""; state.defaultCalendar = (s && s.default_calendar) || "";
state.defaultRemTimed = (s && s.default_reminders_timed) || []; // Reminders are stored as {method, minutes}; event reminders only use minutes.
state.defaultRemAllday = (s && s.default_reminders_allday) || []; 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) */ } } catch { /* keep defaults (24h, public Jitsi) */ }
try { try {
const q = await api.fetch("/settings/quota"); const q = await api.fetch("/settings/quota");
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB