Add UI for share by token
This commit is contained in:
0
integ_tests/__init__.py
Normal file
0
integ_tests/__init__.py
Normal file
99
integ_tests/common.py
Normal file
99
integ_tests/common.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import os
|
||||
import pathlib
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Generator
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
|
||||
def get_free_port():
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def start_radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
|
||||
port = get_free_port()
|
||||
config_path = tmp_path / "config"
|
||||
user_path = tmp_path / "users"
|
||||
storage_path = tmp_path / "collections"
|
||||
|
||||
# Create a local config file
|
||||
with open(config_path, "w") as f:
|
||||
f.write(
|
||||
f"""[server]
|
||||
hosts = 127.0.0.1:{port}
|
||||
[storage]
|
||||
filesystem_folder = {storage_path}
|
||||
[auth]
|
||||
type = htpasswd
|
||||
htpasswd_filename = {user_path}
|
||||
[web]
|
||||
type = internal
|
||||
[sharing]
|
||||
type = csv
|
||||
collection_by_map = true
|
||||
collection_by_token = true
|
||||
permit_create_token = true
|
||||
permit_create_map = true
|
||||
|
||||
"""
|
||||
)
|
||||
with open(user_path, "w") as f:
|
||||
f.write(
|
||||
"""admin:adminpassword
|
||||
"""
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
# Ensure the radicale package is in PYTHONPATH
|
||||
# Assuming this test file is in <repo>/integ_tests/
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "")
|
||||
|
||||
# Run the server
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-m", "radicale", "--config", str(config_path)],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
# Wait for the server to start listening
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < 10:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
|
||||
break
|
||||
except (OSError, ConnectionRefusedError):
|
||||
if process.poll() is not None:
|
||||
_stdout, stderr = process.communicate()
|
||||
raise RuntimeError(
|
||||
f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}"
|
||||
)
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
process.terminate()
|
||||
process.wait()
|
||||
raise RuntimeError("Timeout waiting for Radicale to start")
|
||||
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
|
||||
# Cleanup
|
||||
process.terminate()
|
||||
process.wait()
|
||||
|
||||
|
||||
def login(page: Page, radicale_server: str) -> None:
|
||||
page.goto(radicale_server)
|
||||
page.fill('#loginscene input[data-name="user"]', "admin")
|
||||
page.fill('#loginscene input[data-name="password"]', "adminpassword")
|
||||
page.click('button:has-text("Next")')
|
||||
|
||||
|
||||
def create_collection(page: Page, radicale_server: str) -> None:
|
||||
page.click('.fabcontainer a[data-name="new"]')
|
||||
page.click('#createcollectionscene button[data-name="submit"]')
|
||||
@@ -1,88 +1,20 @@
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import pathlib
|
||||
from typing import Any, Generator
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def get_free_port():
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
from integ_tests.common import login, start_radicale_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def radicale_server(tmp_path):
|
||||
port = get_free_port()
|
||||
config_path = tmp_path / "config"
|
||||
user_path = tmp_path / "users"
|
||||
storage_path = tmp_path / "collections"
|
||||
|
||||
# Create a local config file
|
||||
with open(config_path, "w") as f:
|
||||
f.write(
|
||||
f"""[server]
|
||||
hosts = 127.0.0.1:{port}
|
||||
[storage]
|
||||
filesystem_folder = {storage_path}
|
||||
[auth]
|
||||
type = htpasswd
|
||||
htpasswd_filename = {user_path}
|
||||
[web]
|
||||
type = internal
|
||||
"""
|
||||
)
|
||||
with open(user_path, "w") as f:
|
||||
f.write(
|
||||
"""admin:adminpassword
|
||||
"""
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
# Ensure the radicale package is in PYTHONPATH
|
||||
# Assuming this test file is in <repo>/integ_tests/
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "")
|
||||
|
||||
# Run the server
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-m", "radicale", "--config", str(config_path)],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
# Wait for the server to start listening
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < 10:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
|
||||
break
|
||||
except (OSError, ConnectionRefusedError):
|
||||
if process.poll() is not None:
|
||||
stdout, stderr = process.communicate()
|
||||
raise RuntimeError(
|
||||
f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}"
|
||||
)
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
process.terminate()
|
||||
process.wait()
|
||||
raise RuntimeError("Timeout waiting for Radicale to start")
|
||||
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
|
||||
# Cleanup
|
||||
process.terminate()
|
||||
process.wait()
|
||||
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
|
||||
yield from start_radicale_server(tmp_path)
|
||||
|
||||
|
||||
def test_index_html_loads(page: Page, radicale_server):
|
||||
def test_index_html_loads(page: Page, radicale_server: str) -> None:
|
||||
"""Test that the index.html loads from the server."""
|
||||
console_msgs = []
|
||||
console_msgs: list[str] = []
|
||||
page.on("console", lambda msg: console_msgs.append(msg.text))
|
||||
page.goto(radicale_server)
|
||||
expect(page).to_have_title("Radicale Web Interface")
|
||||
@@ -90,13 +22,9 @@ def test_index_html_loads(page: Page, radicale_server):
|
||||
assert len(console_msgs) == 0
|
||||
|
||||
|
||||
def test_user_login_works(page: Page, radicale_server):
|
||||
def test_user_login_works(page: Page, radicale_server: str) -> None:
|
||||
"""Test that the login form works."""
|
||||
page.goto(radicale_server)
|
||||
# Fill in the login form
|
||||
page.fill('#loginscene input[data-name="user"]', "admin")
|
||||
page.fill('#loginscene input[data-name="password"]', "adminpassword")
|
||||
page.click('button:has-text("Next")')
|
||||
login(page, radicale_server)
|
||||
|
||||
# After login, we should see the collections list (which is empty)
|
||||
expect(
|
||||
|
||||
46
integ_tests/test_sharing.py
Normal file
46
integ_tests/test_sharing.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import pathlib
|
||||
from typing import Any, Generator
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from integ_tests.common import create_collection, login, start_radicale_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
|
||||
yield from start_radicale_server(tmp_path)
|
||||
|
||||
|
||||
def test_create_and_delete_share_by_key(page: Page, radicale_server: str) -> None:
|
||||
login(page, radicale_server)
|
||||
create_collection(page, radicale_server)
|
||||
page.hover("article:not(.hidden)")
|
||||
page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True)
|
||||
|
||||
expect(
|
||||
page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)")
|
||||
).to_have_count(0)
|
||||
|
||||
page.click('button[data-name="sharebytoken_ro"]')
|
||||
expect(
|
||||
page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)")
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden) img[alt='RO']")
|
||||
).to_be_visible()
|
||||
page.click('tr:not(.hidden) button[data-name="delete"]', strict=True)
|
||||
expect(
|
||||
page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)")
|
||||
).to_have_count(0)
|
||||
page.click('button[data-name="sharebytoken_rw"]')
|
||||
expect(
|
||||
page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)")
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden) img[alt='RW']")
|
||||
).to_be_visible()
|
||||
page.click('tr:not(.hidden) button[data-name="delete"]', strict=True)
|
||||
expect(
|
||||
page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)")
|
||||
).to_have_count(0)
|
||||
@@ -98,11 +98,7 @@ radicale = [
|
||||
"web/internal_data/css/loading.svg",
|
||||
"web/internal_data/css/logo.svg",
|
||||
"web/internal_data/css/main.css",
|
||||
"web/internal_data/css/icons/delete.svg",
|
||||
"web/internal_data/css/icons/download.svg",
|
||||
"web/internal_data/css/icons/edit.svg",
|
||||
"web/internal_data/css/icons/new.svg",
|
||||
"web/internal_data/css/icons/upload.svg",
|
||||
"web/internal_data/css/icons/*.svg",
|
||||
"web/internal_data/*.js",
|
||||
"web/internal_data/index.html",
|
||||
"py.typed",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js";
|
||||
import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js";
|
||||
import { CreateShareCollectionScene } from "./ShareCollectionScene.js";
|
||||
import { UploadCollectionScene } from "./UploadCollectionScene.js";
|
||||
import { DeleteCollectionScene } from "./DeleteCollectionScene.js";
|
||||
import { LoadingScene } from "./LoadingScene.js";
|
||||
@@ -78,6 +79,16 @@ export function CollectionsScene(user, password, collection, onerror) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function onshare(collection) {
|
||||
try {
|
||||
let share_collection_scene = new CreateShareCollectionScene(user, password, collection);
|
||||
push_scene(share_collection_scene, false);
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ondelete(collection) {
|
||||
try {
|
||||
let delete_collection_scene = new DeleteCollectionScene(user, password, collection);
|
||||
@@ -102,6 +113,7 @@ export function CollectionsScene(user, password, collection, onerror) {
|
||||
let color_form = node.querySelector("[data-name=color]");
|
||||
let delete_btn = node.querySelector("[data-name=delete]");
|
||||
let edit_btn = node.querySelector("[data-name=edit]");
|
||||
let share_btn = node.querySelector("[data-name=share]");
|
||||
let download_btn = node.querySelector("[data-name=download]");
|
||||
if (collection.color) {
|
||||
color_form.style.background = collection.color;
|
||||
@@ -144,6 +156,7 @@ export function CollectionsScene(user, password, collection, onerror) {
|
||||
}
|
||||
delete_btn.onclick = function() {return ondelete(collection);};
|
||||
edit_btn.onclick = function() {return onedit(collection);};
|
||||
share_btn.onclick = function() {return onshare(collection);};
|
||||
node.classList.remove("hidden");
|
||||
nodes.push(node);
|
||||
template.parentNode.insertBefore(node, template);
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
|
||||
import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js";
|
||||
import { LoadingScene } from "./LoadingScene.js";
|
||||
import { get_principal } from "./api.js";
|
||||
import { get_principal, discover_server_features } from "./api.js";
|
||||
import { CollectionsScene } from "./CollectionsScene.js";
|
||||
import { maybe_enable_sharing_options } from "./ShareCollectionScene.js";
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
@@ -89,6 +90,7 @@ export function LoginScene() {
|
||||
error = error1;
|
||||
user = saved_user;
|
||||
});
|
||||
discover_server_features(saved_user, password, maybe_enable_sharing_options);
|
||||
push_scene(collections_scene, true);
|
||||
}
|
||||
});
|
||||
|
||||
164
radicale/web/internal_data/ShareCollectionScene.js
Normal file
164
radicale/web/internal_data/ShareCollectionScene.js
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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>
|
||||
*
|
||||
* 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 {
|
||||
add_share_by_token,
|
||||
delete_share_by_token,
|
||||
reload_sharing_list,
|
||||
server_features,
|
||||
} from "./api.js";
|
||||
import { pop_scene, scene_stack } from "./scene_manager.js";
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @implements {Scene}
|
||||
* @param {string} user
|
||||
* @param {string} password
|
||||
* @param {Collection} collection The collection on which to edit sharing setting. Must exist.
|
||||
*/
|
||||
export function CreateShareCollectionScene(user, password, collection) {
|
||||
/** @type {?number} */ let scene_index = null;
|
||||
|
||||
let html_scene = document.getElementById("sharecollectionscene");
|
||||
|
||||
let cancel_btn = html_scene.querySelector("[data-name=cancel]");
|
||||
let share_by_token_btn_ro = html_scene.querySelector(
|
||||
"[data-name=sharebytoken_ro]",
|
||||
);
|
||||
let share_by_token_btn_rw = html_scene.querySelector(
|
||||
"[data-name=sharebytoken_rw]",
|
||||
);
|
||||
|
||||
let title = html_scene.querySelector("[data-name=title]");
|
||||
|
||||
function oncancel() {
|
||||
try {
|
||||
pop_scene(scene_index - 1);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onsharebytoken_rw() {
|
||||
add_share_by_token(user, password, collection, "rw", function () {
|
||||
update_share_list(user, password, collection);
|
||||
});
|
||||
}
|
||||
|
||||
function onsharebytoken_ro() {
|
||||
add_share_by_token(user, password, collection, "r", function () {
|
||||
update_share_list(user, password, collection);
|
||||
});
|
||||
}
|
||||
|
||||
this.show = function () {
|
||||
this.release();
|
||||
scene_index = scene_stack.length - 1;
|
||||
html_scene.classList.remove("hidden");
|
||||
cancel_btn.onclick = oncancel;
|
||||
if (server_features["sharing"]["FeatureEnabledCollectionByToken"]) {
|
||||
share_by_token_btn_ro.onclick = onsharebytoken_ro;
|
||||
share_by_token_btn_rw.onclick = onsharebytoken_rw;
|
||||
} else {
|
||||
share_by_token_btn_ro.parentElement.removeChild(share_by_token_btn_ro);
|
||||
share_by_token_btn_rw.parentElement.removeChild(share_by_token_btn_rw);
|
||||
}
|
||||
title.textContent = collection.displayname || collection.href;
|
||||
update_share_list(user, password, collection);
|
||||
};
|
||||
this.hide = function () {
|
||||
html_scene.classList.add("hidden");
|
||||
cancel_btn.onclick = null;
|
||||
};
|
||||
this.release = function () {
|
||||
scene_index = null;
|
||||
};
|
||||
}
|
||||
|
||||
function update_share_list(user, password, collection) {
|
||||
let share_rows = document.querySelectorAll(
|
||||
"[data-name=sharetokenrowtemplate]",
|
||||
);
|
||||
share_rows.forEach(function (row) {
|
||||
if (!row.classList.contains("hidden")) {
|
||||
row.parentNode.removeChild(row);
|
||||
}
|
||||
});
|
||||
|
||||
reload_sharing_list(user, password, collection, function (response) {
|
||||
add_share_rows(user, password, collection, response["Content"] || []);
|
||||
});
|
||||
}
|
||||
|
||||
function add_share_rows(user, password, collection, shares) {
|
||||
let template = document.querySelector("[data-name=sharetokenrowtemplate]");
|
||||
shares.forEach(function (share) {
|
||||
let pathortoken = share["PathOrToken"] || "";
|
||||
let pathmapped = share["PathMapped"] || "";
|
||||
if (
|
||||
collection.href.includes(pathmapped) ||
|
||||
collection.href.includes(pathortoken)
|
||||
) {
|
||||
let node = template.cloneNode(true);
|
||||
node.classList.remove("hidden");
|
||||
node.querySelector("[data-name=pathortoken]").value = pathortoken;
|
||||
let permissions = (share["Permissions"] || "").toLowerCase();
|
||||
if (permissions === "rw") {
|
||||
node
|
||||
.querySelector("[data-name=ro]")
|
||||
.parentNode.removeChild(node.querySelector("[data-name=ro]"));
|
||||
} else if (permissions === "r") {
|
||||
node
|
||||
.querySelector("[data-name=rw]")
|
||||
.parentNode.removeChild(node.querySelector("[data-name=rw]"));
|
||||
} else {
|
||||
console.warn("Unknown permissions", permissions);
|
||||
}
|
||||
node.querySelector("[data-name=delete]").onclick = function () {
|
||||
delete_share_by_token(
|
||||
user,
|
||||
password,
|
||||
share["PathOrToken"],
|
||||
function () {
|
||||
update_share_list(user, password, collection);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
template.parentNode.insertBefore(node, template);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
if (map_is_enabled || token_is_enabled) {
|
||||
let share_options = document.querySelectorAll("[data-name=shareoption]");
|
||||
for (let i = 0; i < share_options.length; i++) {
|
||||
let share_option = share_options[i];
|
||||
share_option.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import { Collection, CollectionType } from "./models.js";
|
||||
import { SERVER, ROOT_PATH, COLOR_RE } from "./constants.js";
|
||||
import { escape_xml } from "./utils.js";
|
||||
|
||||
export let server_features = {};
|
||||
|
||||
/**
|
||||
* Find the principal collection.
|
||||
* @param {string} user
|
||||
@@ -340,3 +342,130 @@ export function create_collection(user, password, collection, callback) {
|
||||
export function edit_collection(user, password, collection, callback) {
|
||||
return create_edit_collection(user, password, collection, false, callback);
|
||||
}
|
||||
/* Sharing API */
|
||||
|
||||
function call_sharing_api(
|
||||
user,
|
||||
password,
|
||||
path,
|
||||
body,
|
||||
on_success,
|
||||
on_not_found = null,
|
||||
on_error = null,
|
||||
) {
|
||||
let request = new XMLHttpRequest();
|
||||
request.open(
|
||||
"POST",
|
||||
SERVER + ROOT_PATH + ".sharing/v1/" + path,
|
||||
true,
|
||||
user,
|
||||
encodeURIComponent(password),
|
||||
);
|
||||
request.onreadystatechange = function () {
|
||||
if (request.readyState !== 4) {
|
||||
return;
|
||||
}
|
||||
if (200 <= request.status && request.status < 300) {
|
||||
on_success(request.responseText);
|
||||
} else if (request.status === 404) {
|
||||
if (on_not_found) {
|
||||
on_not_found();
|
||||
} else if (on_error) {
|
||||
on_error("Not found");
|
||||
} else {
|
||||
console.error("Not found");
|
||||
}
|
||||
} else {
|
||||
if (on_error) {
|
||||
on_error(request.status + " " + request.statusText);
|
||||
} else {
|
||||
console.error(request.status + " " + request.statusText);
|
||||
}
|
||||
}
|
||||
};
|
||||
request.setRequestHeader("Accept", "application/json");
|
||||
request.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
|
||||
request.send(body ? JSON.stringify(body) : null);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function discover_server_features(user, password, callback) {
|
||||
call_sharing_api(
|
||||
user,
|
||||
password,
|
||||
"all/info",
|
||||
{},
|
||||
function (response) {
|
||||
server_features["sharing"] = JSON.parse(response);
|
||||
callback();
|
||||
},
|
||||
function () {
|
||||
// sharing is disabled on the server
|
||||
server_features["sharing"] = {};
|
||||
callback();
|
||||
},
|
||||
function (error) {
|
||||
console.error("Failed to discover sharing features: " + error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function reload_sharing_list(user, password, collection, callback) {
|
||||
call_sharing_api(
|
||||
user,
|
||||
password,
|
||||
"all/list",
|
||||
{ PathMapped: collection.href },
|
||||
function (response) {
|
||||
callback(JSON.parse(response));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function add_share_by_token(
|
||||
user,
|
||||
password,
|
||||
collection,
|
||||
permissions,
|
||||
callback,
|
||||
) {
|
||||
call_sharing_api(
|
||||
user,
|
||||
password,
|
||||
"token/create",
|
||||
{
|
||||
PathMapped: collection.href,
|
||||
Permissions: permissions,
|
||||
},
|
||||
function (response) {
|
||||
let json_response = JSON.parse(response);
|
||||
if (json_response["Status"] !== "success") {
|
||||
console.error("Failed to create share token: " + (json_response["Status"] || "Unknown error"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function delete_share_by_token(
|
||||
user,
|
||||
password,
|
||||
token,
|
||||
callback,
|
||||
) {
|
||||
call_sharing_api(
|
||||
user,
|
||||
password,
|
||||
"token/delete",
|
||||
{ PathOrToken: token },
|
||||
function (response) {
|
||||
let json_response = JSON.parse(response);
|
||||
if (json_response["Status"] !== "success") {
|
||||
console.error("Failed to create delete token " + token + ": " + (json_response["Status"] || "Unknown error"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
21
radicale/web/internal_data/css/icons/credits.md
Normal file
21
radicale/web/internal_data/css/icons/credits.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Credits
|
||||
|
||||
## share.svg
|
||||
|
||||
* <https://github.com/feathericons/feather/blob/main/icons/share-2.svg>
|
||||
* MIT License
|
||||
|
||||
## key.svg
|
||||
|
||||
* <https://github.com/feathericons/feather/blob/main/icons/key.svg>
|
||||
* MIT License
|
||||
|
||||
## repeat.svg
|
||||
|
||||
* <https://github.com/feathericons/feather/blob/main/icons/repeat.svg>
|
||||
* MIT License
|
||||
|
||||
## eye.svg
|
||||
|
||||
* <https://www.svgrepo.com/svg/509920/eye>
|
||||
* MIT License
|
||||
4
radicale/web/internal_data/css/icons/eye.svg
Executable file
4
radicale/web/internal_data/css/icons/eye.svg
Executable file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.30147 15.5771C4.77832 14.2684 3.6904 12.7726 3.18002 12C3.6904 11.2274 4.77832 9.73158 6.30147 8.42294C7.87402 7.07185 9.81574 6 12 6C14.1843 6 16.1261 7.07185 17.6986 8.42294C19.2218 9.73158 20.3097 11.2274 20.8201 12C20.3097 12.7726 19.2218 14.2684 17.6986 15.5771C16.1261 16.9282 14.1843 18 12 18C9.81574 18 7.87402 16.9282 6.30147 15.5771ZM12 4C9.14754 4 6.75717 5.39462 4.99812 6.90595C3.23268 8.42276 2.00757 10.1376 1.46387 10.9698C1.05306 11.5985 1.05306 12.4015 1.46387 13.0302C2.00757 13.8624 3.23268 15.5772 4.99812 17.0941C6.75717 18.6054 9.14754 20 12 20C14.8525 20 17.2429 18.6054 19.002 17.0941C20.7674 15.5772 21.9925 13.8624 22.5362 13.0302C22.947 12.4015 22.947 11.5985 22.5362 10.9698C21.9925 10.1376 20.7674 8.42276 19.002 6.90595C17.2429 5.39462 14.8525 4 12 4ZM10 12C10 10.8954 10.8955 10 12 10C13.1046 10 14 10.8954 14 12C14 13.1046 13.1046 14 12 14C10.8955 14 10 13.1046 10 12ZM12 8C9.7909 8 8.00004 9.79086 8.00004 12C8.00004 14.2091 9.7909 16 12 16C14.2092 16 16 14.2091 16 12C16 9.79086 14.2092 8 12 8Z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
radicale/web/internal_data/css/icons/key.svg
Normal file
1
radicale/web/internal_data/css/icons/key.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-key"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg>
|
||||
|
After Width: | Height: | Size: 352 B |
1
radicale/web/internal_data/css/icons/repeat.svg
Normal file
1
radicale/web/internal_data/css/icons/repeat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-repeat"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>
|
||||
|
After Width: | Height: | Size: 392 B |
1
radicale/web/internal_data/css/icons/share.svg
Normal file
1
radicale/web/internal_data/css/icons/share.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-share-2"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line></svg>
|
||||
|
After Width: | Height: | Size: 445 B |
@@ -39,6 +39,14 @@ main{
|
||||
color: #484848;
|
||||
}
|
||||
|
||||
.container h2{
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
color: #484848;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
#loginscene .infcloudlink{
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
@@ -303,6 +311,29 @@ main{
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.small_icon{
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.med_icon{
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
.badge_icon{
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
filter: invert(1);
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
|
||||
.smalltext{
|
||||
font-size: 75% !important;
|
||||
}
|
||||
@@ -345,6 +376,7 @@ button{
|
||||
margin-left: 10px;
|
||||
background: black;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
input, select{
|
||||
@@ -360,6 +392,10 @@ input, select{
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
input.inline {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
input[type=text], input[type=password]{
|
||||
width: calc(100% - 30px);
|
||||
}
|
||||
@@ -414,6 +450,12 @@ button.blue:active, a.blue:active{
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
button.inline {
|
||||
padding-inline: 1px;
|
||||
min-width: 2em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
#collectionsscene{
|
||||
flex-direction: column !important;
|
||||
|
||||
@@ -90,6 +90,11 @@
|
||||
<img src="css/icons/edit.svg" class="icon" alt="✏️">
|
||||
</a>
|
||||
</li>
|
||||
<li class="hidden" data-name="shareoption">
|
||||
<a href="" title="Share" class="blue" data-name="share">
|
||||
<img src="css/icons/share.svg" class="icon" alt="🔗">
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="" title="Delete" class="red" data-name="delete">
|
||||
<img src="css/icons/delete.svg" class="icon" alt="❌">
|
||||
@@ -133,6 +138,39 @@
|
||||
<br>
|
||||
</section>
|
||||
|
||||
<section id="sharecollectionscene" class="container hidden">
|
||||
<h1>Sharing</h1>
|
||||
<p>Manage sharing for collection <span class="title" data-name="title">title</span>
|
||||
</p>
|
||||
<h2>By Token</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr data-name="sharetokenrowtemplate" class="hidden">
|
||||
<td>
|
||||
<button type="button" class="red inline" data-name="delete"><img src="css/icons/delete.svg" class="small_icon" alt="Share"></button>
|
||||
</td>
|
||||
<td><img
|
||||
src="css/icons/edit.svg" class="med_icon" alt="RW" data-name="rw"/><img
|
||||
src="css/icons/eye.svg" class="med_icon" alt="RO" data-name="ro"/></td>
|
||||
<td><input type="text" data-name="pathortoken" value="" readonly="" onfocus="this.setSelectionRange(0, 99999);" class="inline"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td><button type="button" class="blue inline" data-name="sharebytoken_ro"><img
|
||||
src="css/icons/eye.svg" class="small_icon" alt="RO"><img
|
||||
src="css/icons/new.svg" class="badge_icon" alt="New"></button>
|
||||
<button type="button" class="blue inline" data-name="sharebytoken_rw"><img
|
||||
src="css/icons/edit.svg" class="small_icon" alt="RW"><img
|
||||
src="css/icons/new.svg" class="badge_icon" alt="New"></button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<form>
|
||||
<button type="button" class="green" data-name="cancel">Close</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="createcollectionscene" class="container hidden">
|
||||
<h1>Create a new Collection</h1>
|
||||
<p>Enter the details of your new collection.</p>
|
||||
|
||||
@@ -28,11 +28,7 @@ web_files = ["web/internal_data/css/icon.png",
|
||||
"web/internal_data/css/loading.svg",
|
||||
"web/internal_data/css/logo.svg",
|
||||
"web/internal_data/css/main.css",
|
||||
"web/internal_data/css/icons/delete.svg",
|
||||
"web/internal_data/css/icons/download.svg",
|
||||
"web/internal_data/css/icons/edit.svg",
|
||||
"web/internal_data/css/icons/new.svg",
|
||||
"web/internal_data/css/icons/upload.svg",
|
||||
"web/internal_data/css/icons/*.svg",
|
||||
"web/internal_data/*.js",
|
||||
"web/internal_data/index.html"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user