Merge pull request #2027 from maxberger/master

Display incoming share owner and hide some action buttons
This commit is contained in:
Peter Bieringer
2026-03-16 07:09:45 +01:00
committed by GitHub
11 changed files with 279 additions and 115 deletions

View File

@@ -297,7 +297,8 @@ def test_share_by_map_validation(page: Page, radicale_server: str) -> None:
).to_have_count(1)
def test_incoming_shares(page: Page, radicale_server: str) -> None:
@pytest.mark.parametrize("permissions", ["ro", "rw"])
def test_incoming_shares(page: Page, radicale_server: str, permissions: str) -> None:
# 1. Admin logs in and creates a map share for 'max'
login(page, radicale_server)
create_collection(page, radicale_server)
@@ -307,6 +308,8 @@ def test_incoming_shares(page: Page, radicale_server: str) -> None:
page.click('button[data-name="sharebymap"]')
page.locator('input[data-name="shareuser"]').fill("max")
page.locator('input[data-name="sharehref"]').fill("mapped")
if permissions == "rw":
page.check("#newshare_attr_permissions_rw")
page.click('#newshare button[data-name="submit"]')
expect(
page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)")
@@ -372,12 +375,31 @@ def test_incoming_shares(page: Page, radicale_server: str) -> None:
)
).to_be_checked()
# 6. Assert no error was shown
expect(page.locator('#incomingsharingscene span[data-name="error"]')).to_be_hidden()
# 6. Verify "shared by admin" and button visibility in the collection article
page.click('#incomingsharingscene button[data-name="cancel"]')
expect(page.locator("#incomingsharingscene")).to_be_hidden()
article = page.locator("article:not(.hidden)").first
expect(article.locator('[data-name="shared-by"]')).to_be_visible()
expect(article.locator('[data-name="shared-by-owner"]')).to_have_text("admin")
# Action buttons are only visible on mouseover
article.hover()
# Share and delete buttons should be hidden for all incoming shares
expect(article.locator('a[data-name="share"]')).to_be_hidden()
expect(article.locator('[data-name="shareoption"]')).to_be_hidden()
expect(article.locator('a[data-name="delete"]')).to_be_hidden()
# Edit button depends on permissions
if permissions == "rw":
expect(article.locator('a[data-name="edit"]')).to_be_visible()
else:
expect(article.locator('a[data-name="edit"]')).to_be_hidden()
# 7. Assert no error was shown
expect(page.locator('#incomingsharingscene span[data-name="error"]')).to_be_hidden()
def test_no_incoming_shares_message(page: Page, radicale_server: str) -> None:
# 1. Max logs in

View File

@@ -74,6 +74,7 @@
<div class="colorbar" data-name="color"></div>
<h3 class="title" data-name="title">Title</h3>
<small>
<span data-name="shared-by" class="hidden">👥 shared by <span data-name="shared-by-owner"></span> &gt;</span>
<span data-name="ADDRESSBOOK">Address book</span>
<span data-name="CALENDAR_JOURNAL_TASKS">Calendar, journal and tasks</span>
<span data-name="CALENDAR_JOURNAL">Calendar and journal</span>

View File

@@ -34,9 +34,6 @@ import { CollectionType } from "../models/collection.js";
* @property {SharingFeatures} [sharing]
*/
/** @type {ServerFeatures} */
export let server_features = {};
/**
* @param {string} user
* @param {string} password
@@ -95,7 +92,7 @@ function call_sharing_api(
/**
* @param {string} user
* @param {string} password
* @param {function():void} callback
* @param {function(import("../api/sharing.js").ServerFeatures, ?string):void} callback
*/
export function discover_server_features(user, password, callback) {
call_sharing_api(
@@ -104,16 +101,19 @@ export function discover_server_features(user, password, callback) {
"all/info",
{},
function (response) {
server_features["sharing"] = JSON.parse(response);
callback();
try {
let features = { "sharing": JSON.parse(response) };
callback(features, null);
} catch (e) {
callback({}, e.message);
}
},
function () {
// sharing is disabled on the server
server_features["sharing"] = {};
callback();
callback({ "sharing": {} }, null);
},
function (error) {
console.error("Failed to discover sharing features: " + error);
callback({}, error);
},
);
}
@@ -147,7 +147,7 @@ export class Share {
*/
export function reload_sharing_list(user, password, collection, callback) {
let body = collection ? { PathMapped: collection.href } : {};
call_sharing_api(
return call_sharing_api(
user,
password,
"all/list",
@@ -160,7 +160,7 @@ export function reload_sharing_list(user, password, collection, callback) {
null, // on_not_found
function (error) {
callback([], error);
}
},
);
}

View File

@@ -19,17 +19,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { get_collections } from "../api/api.js";
import { discover_server_features } from "../api/sharing.js";
import { SERVER } from "../constants.js";
import { Collection, CollectionType } from "../models/collection.js";
import { collectionsCache } from "../utils/collections_cache.js";
import { bytesToHumanReadable } from "../utils/misc.js";
import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js";
import { DeleteCollectionScene } from "./DeleteCollectionScene.js";
import { IncomingSharingScene } from "./IncomingSharingScene.js";
import { LoadingScene } from "./LoadingScene.js";
import { Scene, is_current_scene, pop_scene, push_scene } from "./scene_manager.js";
import { Scene, push_scene } from "./scene_manager.js";
import { ShareCollectionScene, maybe_enable_sharing_options } from "./ShareCollectionScene.js";
import { UploadCollectionScene } from "./UploadCollectionScene.js";
@@ -51,8 +48,6 @@ export class CollectionsScene {
/** @type {HTMLElement} */ let upload_btn = html_scene.querySelector("[data-name=upload]");
/** @type {HTMLElement} */ let incomingshares_btn = html_scene.querySelector("[data-name=incomingshares]");
/** @type {?XMLHttpRequest} */ let collections_req = null;
/** @type {?Array<Collection>} */ let child_collections = null;
/** @type {Array<HTMLElement>} */ let nodes = [];
function onnew() {
@@ -126,12 +121,20 @@ export class CollectionsScene {
/**
* @param {any[]} collections
* @param {import("../api/sharing.js").Share[]} shares
*/
function show_collections(collections) {
function show_collections(collections, shares) {
/** @type {HTMLElement} */ let navBar = document.querySelector("#logoutview");
let heightOfNavBar = navBar.offsetHeight + "px";
html_scene.style.marginTop = heightOfNavBar;
html_scene.style.height = "calc(100vh - " + heightOfNavBar + ")";
// Clear old nodes
nodes.forEach(function (node) {
node.parentNode.removeChild(node);
});
nodes = [];
collections.forEach(function (/** @type {Collection} */ collection) {
/** @type {HTMLElement} */ let node = /** @type {HTMLElement} */(template.cloneNode(true));
node.classList.remove("hidden");
@@ -162,6 +165,23 @@ export class CollectionsScene {
node.querySelector("[data-name=" + e + "]").classList.add("hidden");
}
});
let share_info = node.querySelector("[data-name=shared-by]");
let share = (shares || []).find(s => s.ShareType === "map" && (s.PathOrToken || "").replace(/\/+$/, "") === (collection.href || "").replace(/\/+$/, ""));
if (share && share.Owner !== user) {
share_info.classList.remove("hidden");
node.querySelector("[data-name=shared-by-owner]").textContent = share.Owner;
let share_option = node.querySelector("[data-name=shareoption]");
if (share_option) {
share_option.classList.add("hidden");
share_option.removeAttribute("data-name");
}
delete_btn.classList.add("hidden");
if (!/w/i.test(share.Permissions || "")) {
edit_btn.classList.add("hidden");
} else {
edit_btn.classList.remove("hidden");
}
}
title_form.textContent = collection.displayname || collection.href;
if (title_form.textContent.length > 30) {
title_form.classList.add("smalltext");
@@ -192,43 +212,20 @@ export class CollectionsScene {
});
}
function update() {
let loading_scene = new LoadingScene();
push_scene(loading_scene);
collections_req = get_collections(user, password, principal_collection, function (child_collections_, error) {
if (!is_current_scene(loading_scene)) {
return;
}
collections_req = null;
if (error) {
onerror(error);
pop_scene();
} else {
child_collections = child_collections_;
pop_scene();
}
});
}
this.show = function () {
html_scene.classList.remove("hidden");
new_btn.onclick = onnew;
upload_btn.onclick = onupload;
incomingshares_btn.onclick = onincomingshares;
if (child_collections === null) {
update();
discover_server_features(user, password, maybe_enable_sharing_options);
} else {
// from update loading scene
show_collections(child_collections);
}
collectionsCache.getChildCollections(user, password, principal_collection, onerror, show_collections);
collectionsCache.getServerFeatures(user, password, null, maybe_enable_sharing_options);
};
this.hide = function () {
html_scene.classList.add("hidden");
new_btn.onclick = null;
upload_btn.onclick = null;
incomingshares_btn.onclick = null;
child_collections = null;
// remove collection
nodes.forEach(function (node) {
node.parentNode.removeChild(node);
@@ -236,11 +233,6 @@ export class CollectionsScene {
nodes = [];
};
this.release = function () {
if (collections_req !== null) {
collections_req.abort();
collections_req = null;
}
child_collections = null;
};
}
}

View File

@@ -22,6 +22,7 @@
import { create_collection, edit_collection } from "../api/api.js";
import { COLOR_RE } from "../constants.js";
import { Collection, CollectionType } from "../models/collection.js";
import { collectionsCache } from "../utils/collections_cache.js";
import { ErrorHandler } from "../utils/error.js";
import { FormValidator, validate_color, validate_href } from "../utils/form_validator.js";
import { cleanHREFinput, onCleanHREFinput, random_hex, random_uuid } from "../utils/misc.js";
@@ -140,6 +141,7 @@ export class CreateEditCollectionScene {
errorHandler.setError(error1);
pop_scene();
} else {
collectionsCache.invalidate();
pop_to_parent();
}
};

View File

@@ -22,6 +22,7 @@
import { delete_collection } from "../api/api.js";
import { DELETE_CONFIRMATION_TEXT } from "../constants.js";
import { Collection } from "../models/collection.js";
import { collectionsCache } from "../utils/collections_cache.js";
import { ErrorHandler } from "../utils/error.js";
import { FormValidator, validate_equals } from "../utils/form_validator.js";
import { LoadingScene } from "./LoadingScene.js";
@@ -72,6 +73,7 @@ export class DeleteCollectionScene {
errorHandler.setError(error1);
pop_scene();
} else {
collectionsCache.invalidate();
pop_to_parent();
}
});

View File

@@ -19,11 +19,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Share, reload_sharing_list, update_incoming_share } from "../api/sharing.js";
import { update_incoming_share } from "../api/sharing.js";
import { collectionsCache } from "../utils/collections_cache.js";
import { ErrorHandler } from "../utils/error.js";
import { displayPermissions } from "../utils/permissions.js";
import { LoadingScene } from "./LoadingScene.js";
import { Scene, is_current_scene, pop_scene, push_scene } from "./scene_manager.js";
import { Scene, pop_scene } from "./scene_manager.js";
/**
* @implements {Scene}
@@ -45,14 +45,13 @@ export class IncomingSharingScene {
let error_handler = new ErrorHandler(error_element);
/** @type {Array<HTMLElement>} */ let nodes = [];
/** @type {?Array<Object>} */ let shares_cache = null;
function on_cancel() {
pop_scene();
}
/**
* @param {Share} share
* @param {import("../api/sharing.js").Share} share
* @param {HTMLElement} node
*/
function toggle_share(share, node) {
@@ -79,12 +78,14 @@ export class IncomingSharingScene {
enabled_cb.checked = old_enabled;
shown_cb.checked = !old_hidden;
shown_cb.disabled = !old_enabled;
} else {
collectionsCache.invalidate();
}
});
}
/**
* @param {Share[]} shares
* @param {import("../api/sharing.js").Share[]} shares
*/
function render_shares(shares) {
// clear old nodes
@@ -138,34 +139,11 @@ export class IncomingSharingScene {
});
}
function update() {
let loading_scene = new LoadingScene();
push_scene(loading_scene);
error_handler.clearError();
reload_sharing_list(user, password, null, function (shares, error) {
if (!is_current_scene(loading_scene)) {
return;
}
if (error) {
error_handler.setError(error);
pop_scene();
} else {
shares_cache = shares;
pop_scene();
}
});
}
this.show = function () {
html_scene.classList.remove("hidden");
cancel_btn.onclick = on_cancel;
if (shares_cache === null) {
update();
} else {
render_shares(shares_cache);
}
error_handler.clearError();
collectionsCache.getIncomingShares(user, password, error_handler.setError, render_shares);
};
this.hide = function () {
@@ -182,7 +160,6 @@ export class IncomingSharingScene {
}
});
nodes = [];
shares_cache = null;
};
}
}

View File

@@ -20,6 +20,7 @@
*/
import { get_principal } from "../api/api.js";
import { collectionsCache } from "../utils/collections_cache.js";
import { ErrorHandler } from "../utils/error.js";
import { FormValidator, validate_non_empty } from "../utils/form_validator.js";
import { CollectionsScene } from "./CollectionsScene.js";
@@ -58,6 +59,7 @@ export class LoginScene {
function onlogin() {
try {
collectionsCache.invalidate();
read_form();
let password = password_form.value;
if (!validator.validate()) {
@@ -99,6 +101,7 @@ export class LoginScene {
let onlogout = function () {
try {
collectionsCache.invalidate();
user = "";
pop_to_root();
} catch (err) {
@@ -118,6 +121,7 @@ export class LoginScene {
function refresh() {
// The easiest way to refresh is to push a LoadingScene onto the stack and then pop it
// forcing the scene below it, the Collections Scene to refresh itself.
collectionsCache.invalidate();
push_scene(new LoadingScene());
pop_scene();
}

View File

@@ -23,9 +23,9 @@ import {
delete_share_by_map,
delete_share_by_token,
reload_sharing_list,
server_features,
} from "../api/sharing.js";
import { Collection } from "../models/collection.js";
import { collectionsCache } from "../utils/collections_cache.js";
import { ErrorHandler } from "../utils/error.js";
import { displayPermissions } from "../utils/permissions.js";
import { CreateEditShareScene } from "./CreateEditShareScene.js";
@@ -86,35 +86,38 @@ export class ShareCollectionScene {
this.release();
html_scene.classList.remove("hidden");
cancel_btn.onclick = oncancel;
if (server_features.sharing && server_features.sharing.PermittedCreateCollectionByToken) {
if (share_by_token_btn) {
share_by_token_btn.classList.remove("hidden");
share_by_token_btn.onclick = onsharebytoken;
collectionsCache.getServerFeatures(user, password, errorHandler.setError, (features) => {
if (features.sharing && features.sharing.PermittedCreateCollectionByToken) {
if (share_by_token_btn) {
share_by_token_btn.classList.remove("hidden");
share_by_token_btn.onclick = onsharebytoken;
}
} else {
if (share_by_token_btn) share_by_token_btn.classList.add("hidden");
}
} else {
if (share_by_token_btn) share_by_token_btn.classList.add("hidden");
}
if (server_features.sharing && server_features.sharing.FeatureEnabledCollectionByToken) {
if (share_by_token_div) share_by_token_div.classList.remove("hidden");
} else {
if (share_by_token_div) share_by_token_div.classList.add("hidden");
}
if (server_features.sharing && server_features.sharing.PermittedCreateCollectionByMap) {
if (share_by_map_btn) {
share_by_map_btn.classList.remove("hidden");
share_by_map_btn.onclick = onsharebymap;
if (features.sharing && features.sharing.FeatureEnabledCollectionByToken) {
if (share_by_token_div) share_by_token_div.classList.remove("hidden");
} else {
if (share_by_token_div) share_by_token_div.classList.add("hidden");
}
} else {
if (share_by_map_btn) share_by_map_btn.classList.add("hidden");
}
if (server_features.sharing && server_features.sharing.FeatureEnabledCollectionByMap) {
if (share_by_map_div) share_by_map_div.classList.remove("hidden");
} else {
if (share_by_map_div) share_by_map_div.classList.add("hidden");
}
if (features.sharing && features.sharing.PermittedCreateCollectionByMap) {
if (share_by_map_btn) {
share_by_map_btn.classList.remove("hidden");
share_by_map_btn.onclick = onsharebymap;
}
} else {
if (share_by_map_btn) share_by_map_btn.classList.add("hidden");
}
if (features.sharing && features.sharing.FeatureEnabledCollectionByMap) {
if (share_by_map_div) share_by_map_div.classList.remove("hidden");
} else {
if (share_by_map_div) share_by_map_div.classList.add("hidden");
}
});
title.textContent = collection.displayname || collection.href;
update_share_list(user, password, collection, errorHandler);
@@ -231,10 +234,13 @@ function add_share_rows(user, password, collection, shares, errorHandler) {
});
}
export function maybe_enable_sharing_options() {
if (!server_features.sharing) return;
let map_is_enabled = server_features.sharing.FeatureEnabledCollectionByMap || false;
let token_is_enabled = server_features.sharing.FeatureEnabledCollectionByToken || false;
/**
* @param {import('../api/sharing.js').ServerFeatures} features
*/
export function maybe_enable_sharing_options(features) {
if (!features || !features.sharing) return;
let map_is_enabled = features.sharing.FeatureEnabledCollectionByMap || false;
let token_is_enabled = features.sharing.FeatureEnabledCollectionByToken || false;
if (map_is_enabled || token_is_enabled) {
let share_options = document.querySelectorAll("[data-name=shareoption]");
for (let i = 0; i < share_options.length; i++) {

View File

@@ -21,6 +21,7 @@
import { upload_collection } from "../api/api.js";
import { Collection } from "../models/collection.js";
import { collectionsCache } from "../utils/collections_cache.js";
import { ErrorHandler } from "../utils/error.js";
import { FormValidator, validate_files, validate_href } from "../utils/form_validator.js";
import { cleanHREFinput, onCleanHREFinput, random_uuid } from "../utils/misc.js";
@@ -127,6 +128,9 @@ export class UploadCollectionScene {
function onclose() {
try {
if (results.length > 0) {
collectionsCache.invalidate();
}
pop_scene();
} catch (err) {
console.error(err);

View File

@@ -0,0 +1,154 @@
/**
* This file is part of Radicale Server - Calendar Server
* Copyright © 2017-2024 Unrud <unrud@outlook.com>
* Copyright © 2023-2024 Matthew Hana <matthew.hana@gmail.com>
* Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
* Copyright © 2026-2026 Max Berger <max@berger.name>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { get_collections } from "../api/api.js";
import { discover_server_features, reload_sharing_list } from "../api/sharing.js";
import { LoadingScene } from "../scenes/LoadingScene.js";
import { is_current_scene, pop_scene, push_scene } from "../scenes/scene_manager.js";
class CollectionsCache {
constructor() {
this.child_collections = null;
/** @type {?Array<import("../api/sharing.js").Share>} */ this.incoming_shares = null;
this.server_features = null;
/** @type {?XMLHttpRequest} */ this.collections_req = null;
/** @type {?XMLHttpRequest} */ this.shares_req = null;
}
invalidate() {
this.child_collections = null;
this.incoming_shares = null;
this.server_features = null;
if (this.collections_req) {
this.collections_req.abort();
this.collections_req = null;
}
if (this.shares_req) {
this.shares_req.abort();
this.shares_req = null;
}
}
/**
* @param {string} user
* @param {string} password
* @param {import("../models/collection.js").Collection} principal_collection
* @param {function(string):void} onerror
* @param {function(Array<import("../models/collection.js").Collection>, Array<import("../api/sharing.js").Share>):void} displayData
*/
getChildCollections(user, password, principal_collection, onerror, displayData) {
if (this.child_collections !== null && this.incoming_shares !== null) {
displayData(this.child_collections, this.incoming_shares);
return;
}
let loading_scene = new LoadingScene();
push_scene(loading_scene);
let collections = null;
let shares = null;
let error = null;
let check_if_completed = () => {
if (!is_current_scene(loading_scene)) {
return;
}
if (error) {
onerror(error);
pop_scene();
} else if (collections !== null && shares !== null) {
this.child_collections = collections;
this.incoming_shares = shares;
displayData(this.child_collections, this.incoming_shares);
pop_scene();
}
};
this.collections_req = get_collections(user, password, principal_collection, (c, e) => {
this.collections_req = null;
if (e) error = e;
collections = c || [];
check_if_completed();
});
this.shares_req = reload_sharing_list(user, password, null, (s, e) => {
this.shares_req = null;
if (e) error = e;
shares = s || [];
check_if_completed();
});
}
/**
* @param {string} user
* @param {string} password
* @param {function(string):void} onerror
* @param {function(Array<import("../api/sharing.js").Share>):void} displayData
*/
getIncomingShares(user, password, onerror, displayData) {
if (this.incoming_shares !== null) {
displayData(this.incoming_shares);
return;
}
let loading_scene = new LoadingScene();
push_scene(loading_scene);
this.shares_req = reload_sharing_list(user, password, null, (shares, error) => {
if (!is_current_scene(loading_scene)) {
return;
}
this.shares_req = null;
if (error) {
onerror(error);
pop_scene();
} else {
this.incoming_shares = shares;
displayData(this.incoming_shares);
pop_scene();
}
});
}
/**
* @param {string} user
* @param {string} password
* @param {function(string):void} onerror
* @param {function(import("../api/sharing.js").ServerFeatures):void} displayData
*/
getServerFeatures(user, password, onerror, displayData) {
if (this.server_features !== null) {
displayData(this.server_features);
return;
}
discover_server_features(user, password, (features, error) => {
if (error) {
if (onerror) onerror(error);
} else {
this.server_features = features;
displayData(this.server_features);
}
});
}
}
export const collectionsCache = new CollectionsCache();