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:
+58
-10
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
) =>
|
) =>
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,39 +1188,71 @@ 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">
|
||||||
|
<BellIcon className="mt-2.5 h-4 w-4 shrink-0 text-app-muted" />
|
||||||
|
<div className="flex-1 space-y-2 rounded-lg border border-app-border p-2">
|
||||||
<select
|
<select
|
||||||
className="rounded border border-app-border bg-app-surface px-2 py-1 text-sm text-app-fg outline-none"
|
className={REM_SELECT_CLASS}
|
||||||
value={m}
|
value={r.method}
|
||||||
onChange={(e) => onChange(value.map((x, idx) => (idx === i ? Number(e.target.value) : x)))}
|
onChange={(e) => update(i, { method: e.target.value as ReminderPref["method"] })}
|
||||||
|
>
|
||||||
|
<option value="alert" className="bg-app-surface text-app-fg">
|
||||||
|
{t("settings.showAlert")}
|
||||||
|
</option>
|
||||||
|
<option value="email" className="bg-app-surface text-app-fg">
|
||||||
|
{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) => (
|
{REMINDER_OPTS.map((o) => (
|
||||||
<option key={o} value={o} className="bg-app-surface text-app-fg">
|
<option key={o} value={o} className="bg-app-surface text-app-fg">
|
||||||
@@ -1089,8 +1260,9 @@ function ReminderList({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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 |
Reference in New Issue
Block a user