Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 58 additions & 15 deletions src/backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.urls import path, reverse

from . import models

Expand Down Expand Up @@ -121,12 +124,65 @@ class ChannelAdmin(admin.ModelAdmin):
"last_used_at",
"created_at",
)
list_filter = ("type", "is_active")
list_filter = ("type", "scope_level", "is_active")
search_fields = ("name", "user__email", "caldav_path")
exclude = ("encrypted_settings",)
readonly_fields = ("id", "created_at", "updated_at", "last_used_at")
raw_id_fields = ("user", "organization")
actions = ("regenerate_tokens",)
change_form_template = "admin/core/channel/change_form.html"

def get_urls(self):
urls = super().get_urls()
custom = [
path(
"<uuid:object_id>/rotate-token/",
self.admin_site.admin_view(self.rotate_token_view),
name="core_channel_rotate_token",
),
]
return custom + urls

def rotate_token_view(self, request, object_id):
"""Rotate a single channel's token from the change form button.

POST-only: rotating mutates state, so we don't expose a GET handler.
Renders the same success template as the bulk action so the new
token is shown exactly once.
"""
if request.method != "POST":
return HttpResponseRedirect(
reverse("admin:core_channel_change", args=[object_id])
)
channel = get_object_or_404(models.Channel, pk=object_id)
token, password = self._rotate_one(channel)
context = {
**self.admin_site.each_context(request),
"title": "Regenerated channel tokens",
"results": [(channel, token, password)],
}
return TemplateResponse(
request, "admin/core/channel/regenerated_tokens.html", context
)

@staticmethod
def _rotate_one(channel):
"""Mint a new token for one channel and return (token, caldav_password).

``caldav_password`` is ``base64url(channel_id) + token`` for CalDAV
channels (the HTTP Basic Auth password), and ``None`` otherwise.
"""
token = secrets.token_urlsafe(16)
channel.encrypted_settings = {
**channel.encrypted_settings,
"token": token,
}
channel.save(update_fields=["encrypted_settings", "updated_at"])
password = None
if channel.type == "caldav":
short_id = models.uuid_to_urlsafe(channel.pk)
password = f"{short_id}{token}"
return token, password

@admin.action(description="Regenerate token for selected channels")
def regenerate_tokens(self, request, queryset):
Expand All @@ -141,20 +197,7 @@ def regenerate_tokens(self, request, queryset):
(``base64url(channel_id)`` concatenated with ``token``) is shown
alongside the raw token.
"""
results = []
for channel in queryset:
token = secrets.token_urlsafe(16)
channel.encrypted_settings = {
**channel.encrypted_settings,
"token": token,
}
channel.save(update_fields=["encrypted_settings", "updated_at"])
password = None
if channel.type == "caldav":
short_id = models.uuid_to_urlsafe(channel.pk)
password = f"{short_id}{token}"
results.append((channel, token, password))

results = [(channel, *self._rotate_one(channel)) for channel in queryset]
context = {
**self.admin_site.each_context(request),
"title": "Regenerated channel tokens",
Expand Down
18 changes: 18 additions & 0 deletions src/backend/core/templates/admin/core/channel/change_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{% extends "admin/change_form.html" %}

{% block object-tools-items %}
{% if original.pk %}
<li>
<form action="{% url 'admin:core_channel_rotate_token' original.pk %}"
method="post"
style="display:inline;"
onsubmit="return confirm('Rotate the token for this channel? The current token will be invalidated immediately.');">
{% csrf_token %}
<button type="submit" class="historylink" style="cursor:pointer;border:0;">
Rotate token
</button>
</form>
</li>
{% endif %}
{{ block.super }}
{% endblock %}
7 changes: 7 additions & 0 deletions src/backend/core/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,19 @@ def truncate_caldav_tables(request, django_db_setup, django_db_blocker): # pyli
# Read from the environment so this stays in sync with the compose
# config and can be overridden in CI without editing code.
caldav_test_db = os.environ.get("CALDAV_TEST_DB", "caldav_test")
# SabreDAV tables live in a dedicated schema (CALDAV_DB_SCHEMA, set to
# "sabre" in env.d/development/caldav-test.defaults) so they don't
# share namespace with Django's `test_calendars` tables. Mirror that
# search_path here — otherwise the unqualified TRUNCATEs below fall
# back to "public" and fail with UndefinedTable.
caldav_test_schema = os.environ.get("CALDAV_DB_SCHEMA", "sabre")
conn = psycopg.connect(
host=db_settings["HOST"],
port=db_settings["PORT"],
dbname=caldav_test_db,
user=db_settings["USER"],
password=db_settings["PASSWORD"],
options=f"-c search_path={caldav_test_schema},public",
)
conn.autocommit = True
try:
Expand Down
93 changes: 93 additions & 0 deletions src/backend/core/tests/test_cross_org_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -2206,6 +2206,99 @@ def test_proppatch_color_own_calendar(self):
)
assert "#e74c3c" in check.content.decode("utf-8", errors="ignore")

def test_proppatch_calendar_order_own_calendar(self):
"""Owner can PROPPATCH ``{http://apple.com/ns/ical/}calendar-order``.

We rely on this property to persist the user's manual sidebar
ordering; the frontend writes it via PROPPATCH and reads it back
via PROPFIND. Sabre's PropertyStorage plugin handles it as a dead
property (no schema change), so this test guards against
regressions if that plugin is ever reordered or removed.
"""
org = factories.OrganizationFactory(external_id="proto-order")
owner, owner_client, cal_path = _create_user_with_calendar(org, "owner-order")
cal_id = _get_cal_id(cal_path)
cal_url = f"/caldav/calendars/users/{owner.email}/{cal_id}/"

resp = _proppatch(
owner_client,
cal_url,
"<A:calendar-order>42</A:calendar-order>",
)
assert resp.status_code == 207

check = owner_client.generic(
"PROPFIND",
cal_url,
data=(
'<?xml version="1.0"?>'
'<propfind xmlns="DAV:" xmlns:A="http://apple.com/ns/ical/">'
"<prop><A:calendar-order/></prop>"
"</propfind>"
),
content_type="application/xml",
HTTP_DEPTH="0",
)
assert check.status_code == 207
ns = {"d": "DAV:", "a": "http://apple.com/ns/ical/"}
order_value = None
for response in ET.fromstring(check.content).findall("d:response", ns):
href_el = response.find("d:href", ns)
if href_el is None or not (href_el.text or "").rstrip("/").endswith(cal_id):
continue
order_el = response.find(".//a:calendar-order", ns)
if order_el is not None:
order_value = order_el.text
break
assert order_value == "42"

def test_proppatch_calendar_order_overwrites(self):
"""Subsequent PROPPATCHes of calendar-order overwrite the prior value.

The move handler in the frontend rewrites the order of every
affected sibling on each reorder, so each calendar may receive
multiple PROPPATCHes in a short window. The latest value must win.
"""
org = factories.OrganizationFactory(external_id="proto-order-up")
owner, owner_client, cal_path = _create_user_with_calendar(
org, "owner-order-up"
)
cal_id = _get_cal_id(cal_path)
cal_url = f"/caldav/calendars/users/{owner.email}/{cal_id}/"

for value in ("100", "200", "300"):
resp = _proppatch(
owner_client,
cal_url,
f"<A:calendar-order>{value}</A:calendar-order>",
)
assert resp.status_code == 207

check = owner_client.generic(
"PROPFIND",
cal_url,
data=(
'<?xml version="1.0"?>'
'<propfind xmlns="DAV:" xmlns:A="http://apple.com/ns/ical/">'
"<prop><A:calendar-order/></prop>"
"</propfind>"
),
content_type="application/xml",
HTTP_DEPTH="0",
)
assert check.status_code == 207
ns = {"d": "DAV:", "a": "http://apple.com/ns/ical/"}
order_value = None
for response in ET.fromstring(check.content).findall("d:response", ns):
href_el = response.find("d:href", ns)
if href_el is None or not (href_el.text or "").rstrip("/").endswith(cal_id):
continue
order_el = response.find(".//a:calendar-order", ns)
if order_el is not None:
order_value = order_el.text
break
assert order_value == "300"


# ===================================================================
# ResourceAutoSchedulePlugin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const CalendarItemMenu = ({
onShare,
onImport,
onSubscription,
onMoveUp,
onMoveDown,
}: CalendarItemMenuProps) => {
const { t } = useTranslation();

Expand Down Expand Up @@ -54,14 +56,30 @@ export const CalendarItemMenu = ({
});
}

if (onMoveUp) {
items.push({
label: t("calendar.list.moveUp"),
icon: <span className="material-icons">arrow_upward</span>,
callback: onMoveUp,
});
}

if (onMoveDown) {
items.push({
label: t("calendar.list.moveDown"),
icon: <span className="material-icons">arrow_downward</span>,
callback: onMoveDown,
});
}

items.push({
label: t("calendar.list.delete"),
icon: <span className="material-icons">delete</span>,
callback: onDelete,
});

return items;
}, [t, onEdit, onDelete, onShare, onImport, onSubscription]);
}, [t, onEdit, onDelete, onShare, onImport, onSubscription, onMoveUp, onMoveDown]);

return (
<DropdownMenu options={options} isOpen={isOpen} onOpenChange={onOpenChange}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { useTranslation } from "react-i18next";

import { useCalendarContext } from "../../contexts";
import { setupCalendar } from "@/features/mailbox/api";
import {
addToast,
ToasterItem,
} from "@/features/ui/components/toaster/Toaster";

import { CalendarModal } from "./CalendarModal";
import { CalendarShareModal } from "./CalendarShareModal";
Expand All @@ -30,6 +34,7 @@ export const CalendarList = () => {
createCalendar,
updateCalendar,
deleteCalendar,
moveCalendar,
refreshCalendars,
calendarRef,
isLoading: isCalendarLoading,
Expand Down Expand Up @@ -129,6 +134,39 @@ export const CalendarList = () => {
}
}, [calendarRef]);

const handleMoveUp = useCallback(
(calendar: CalDavCalendar) => {
// moveCalendar resolves with `{success: false, error}` when any
// PROPPATCH fails — surface that to the user instead of swallowing
// it (and instead of leaving the visible state quietly stale).
void moveCalendar(calendar.url, "up").then((result) => {
if (!result.success) {
addToast(
<ToasterItem type="error" closeButton>
{result.error || t("calendar.error.fetchCalendars")}
</ToasterItem>,
);
}
});
},
[moveCalendar, t],
);

const handleMoveDown = useCallback(
(calendar: CalDavCalendar) => {
void moveCalendar(calendar.url, "down").then((result) => {
if (!result.success) {
addToast(
<ToasterItem type="error" closeButton>
{result.error || t("calendar.error.fetchCalendars")}
</ToasterItem>,
);
}
});
},
[moveCalendar, t],
);

return (
<>
<div className="calendar-list">
Expand Down Expand Up @@ -162,7 +200,7 @@ export const CalendarList = () => {
</div>
{isMyCalendarsExpanded && (
<div className="calendar-list__items">
{ownedCalendars.map((calendar) => (
{ownedCalendars.map((calendar, idx) => (
<CalendarListItem
key={calendar.url}
calendar={calendar}
Expand All @@ -176,6 +214,10 @@ export const CalendarList = () => {
onShare={handleOpenShareModal}
onImport={handleOpenImportModal}
onSubscription={handleOpenSubscriptionModal}
onMoveUp={idx > 0 ? handleMoveUp : undefined}
onMoveDown={
idx < ownedCalendars.length - 1 ? handleMoveDown : undefined
}
onCloseMenu={handleCloseMenu}
/>
))}
Expand Down Expand Up @@ -207,7 +249,7 @@ export const CalendarList = () => {
</div>
{isSharedCalendarsExpanded && (
<div className="calendar-list__items">
{sharedCalendars.map((calendar) => (
{sharedCalendars.map((calendar, idx) => (
<CalendarListItem
key={calendar.url}
calendar={calendar}
Expand All @@ -220,6 +262,12 @@ export const CalendarList = () => {
onDelete={handleOpenDeleteModal}
onImport={handleOpenImportModal}
onSubscription={handleOpenSubscriptionModal}
onMoveUp={idx > 0 ? handleMoveUp : undefined}
onMoveDown={
idx < sharedCalendars.length - 1
? handleMoveDown
: undefined
}
onCloseMenu={handleCloseMenu}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export const CalendarListItem = ({
onShare,
onImport,
onSubscription,
onMoveUp,
onMoveDown,
onCloseMenu,
}: CalendarListItemProps) => {
const { t } = useTranslation();
Expand Down Expand Up @@ -81,6 +83,8 @@ export const CalendarListItem = ({
onSubscription={
onSubscription ? () => onSubscription(calendar) : undefined
}
onMoveUp={onMoveUp ? () => onMoveUp(calendar) : undefined}
onMoveDown={onMoveDown ? () => onMoveDown(calendar) : undefined}
/>
</div>
</div>
Expand Down
Loading
Loading