Shares page: own calendar and contact links and loans for everyone

- GET /api/pim/shares lists the caller's own feed links and loans; changes
  and removals reuse the collection routes
- The Shares page shows them for every user in collapsible groups; admins
  also see the other owners' feeds, their own ones only once
- Loan rows change the mode or end the loan; feed rows copy, subscribe
  (calendars) and revoke
- Share rows wrap on phones, and their selects match the other selects

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit2a3f377d2d060c01020cee6c1813d2427f9a395e
Parent9ea2dc7
12 files changed, 400 insertions(+), 135 deletions(-)
▾MREADME.md
@@ -288,9 +288,10 @@ name, color, description, "show as free", sharing, public links, import,
download and delete. When sharing, the name field suggests the accounts
the collection is not lent to yet. The upload button next to **+**
creates a collection from an `.ics` or `.vcf` file. "Connect an app" shows
the addresses and user name for apps. Admins find rooms and resources on
the Users page, and every public feed on the Shares page, grouped by
owner.
the addresses and user name for apps. The Shares page lists every user's
own public links and what they share with other users, next to the file
links. Admins also see the other owners' feeds there, grouped by owner,
and find rooms and resources on the Users page.
The Calendar page has three views:
@@ -386,9 +387,10 @@ and keep them in sync, lend the address book instead (see Sharing).
The JSON API: `GET` and `POST /api/pim/collections/<id>/links`, with
`{"busy_only": true, "expires_at": "<RFC 3339>", "password": "<password>"}`
(all optional), and `DELETE /api/pim/collections/<id>/links/<link id>`.
The answer holds the link's path. Admins see every link with its owner at
`GET /api/admin/pim-links`, and revoke one with `DELETE
/api/admin/pim-links/<id>`.
The answer holds the link's path. `GET /api/pim/shares` lists the
signed-in user's own links and loans across all collections. Admins see
every link with its owner at `GET /api/admin/pim-links`, and revoke one
with `DELETE /api/admin/pim-links/<id>`.
### Import and export
▾Mapi-types/src/lib.rs
@@ -112,6 +112,9 @@ pub const PIM_CONTACTS: &str = "/api/pim/contacts";
/// `GET`: the invitations the signed-in user has not answered, as
/// [`PimInvitation`]s. `POST` a [`PimReply`] to answer one.
pub const PIM_INVITATIONS: &str = "/api/pim/invitations";
/// `GET`: the signed-in user's own feed links and loans, as [`PimOwnShares`].
/// Changing or ending one goes through the collection's routes.
pub const PIM_SHARES: &str = "/api/pim/shares";
/// Public feed of one calendar or address book: `{FEED}/{token}.ics` or
/// `.vcf`. The extension is optional.
pub const FEED: &str = "/feed";
@@ -847,7 +850,8 @@ pub struct PimLinkInfo {
pub has_password: bool,
}
/// One feed with its collection and owner: GET `{ADMIN_PIM_LINKS}`.
/// One feed with its collection and owner: `GET {ADMIN_PIM_LINKS}`, and the
/// own ones in `GET {PIM_SHARES}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AdminPimLink {
#[serde(flatten)]
@@ -861,6 +865,23 @@ pub struct AdminPimLink {
pub owner_active: bool,
}
/// One loan of an own collection: `GET {PIM_SHARES}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PimLend {
pub collection_id: i64,
pub collection_name: String,
pub kind: PimCollectionKind,
#[serde(flatten)]
pub share: PimShareInfo,
}
/// `GET {PIM_SHARES}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PimOwnShares {
pub links: Vec<AdminPimLink>,
pub lends: Vec<PimLend>,
}
/// `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
#[derive(Serialize, Deserialize, Default)]
pub struct CreatePimLink {
▾Mserver/src/api/admin.rs
@@ -286,20 +286,8 @@ pub async fn list_pim_links(
State(state): State<Arc<AppState>>,
_admin: AdminGuard,
) -> Result<Json<Vec<AdminPimLink>>, ApiError> {
let rows = state.db.all_pim_links().await?;
Ok(Json(
rows.into_iter()
.map(|r| AdminPimLink {
link: pim_api::link_info(&r.link, r.kind),
collection_id: r.link.collection_id,
collection_name: r.collection_name,
kind: pim_api::wire_kind(r.kind),
owner_id: r.owner_id,
owner_name: r.owner_name,
owner_active: r.owner_active,
})
.collect(),
))
let rows = state.db.pim_links_with_owner(None).await?;
Ok(Json(rows.into_iter().map(pim_api::feed_entry).collect()))
}
/// DELETE {ADMIN_PIM_LINKS}/{id} — revoke a feed whoever made it.
▾Mserver/src/api/mod.rs
@@ -6,8 +6,8 @@ use api_types::{
AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, CANDIDATES_SUFFIX, DAV, DAV_SHARE,
EXPORT_SUFFIX, FEED, FILES, FINISH_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, OBJECTS_SUFFIX,
PHOTO_SUFFIX, PIM, PIM_COLLECTIONS, PIM_CONTACTS, PIM_IMPORT_NEW, PIM_INSTANCES,
PIM_INVITATIONS, PIM_SYSTEM_EXPORT, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX,
WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
PIM_INVITATIONS, PIM_SHARES, PIM_SYSTEM_EXPORT, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
SHARES_SUFFIX, WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -205,6 +205,7 @@ pub fn router(state: Arc<AppState>) -> Router {
PIM_INVITATIONS,
get(pim_views::invitations).post(pim_views::reply),
)
.route(PIM_SHARES, get(pim_api::own_shares))
.route(&pim_shares, get(pim_api::shares).post(pim_api::share))
.route(&pim_share, delete(pim_api::unshare))
.route(&pim_candidates, get(pim_api::share_candidates))
▾Mserver/src/api/pim_api.rs
@@ -5,6 +5,7 @@
//! - `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — lend it, or change a loan
//! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}` — who it can be lent to
//! - `DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}` — end a loan
//! - `GET {PIM_SHARES}` — the own feed links and loans
//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}` — public feeds of an own collection
//! - `DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}` — revoke a feed
//! - `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}` — import a file
@@ -20,9 +21,9 @@ use std::collections::HashMap;
use std::sync::Arc;
use api_types::{
CreatePimCollection, CreatePimLink, CreatePimShare, FEED, OkResp, PimCollectionInfo,
PimCollectionKind, PimImportNew, PimImportResult, PimLinkInfo, PimShareCandidate, PimShareInfo,
PimShareMode, PimSkipped, UpdatePimCollection,
AdminPimLink, CreatePimCollection, CreatePimLink, CreatePimShare, FEED, OkResp,
PimCollectionInfo, PimCollectionKind, PimImportNew, PimImportResult, PimLend, PimLinkInfo,
PimOwnShares, PimShareCandidate, PimShareInfo, PimShareMode, PimSkipped, UpdatePimCollection,
};
use axum::Json;
use axum::body::Body;
@@ -544,6 +545,45 @@ pub(super) fn link_info(link: &PimLink, kind: PimKind) -> PimLinkInfo {
}
}
pub fn feed_entry(r: crate::db::PimLinkWithOwner) -> AdminPimLink {
AdminPimLink {
link: link_info(&r.link, r.kind),
collection_id: r.link.collection_id,
collection_name: r.collection_name,
kind: wire_kind(r.kind),
owner_id: r.owner_id,
owner_name: r.owner_name,
owner_active: r.owner_active,
}
}
/// GET {PIM_SHARES}
pub async fn own_shares(
State(state): State<Arc<AppState>>,
auth: SessionUser,
) -> Result<Json<PimOwnShares>, ApiError> {
let links = state.db.pim_links_with_owner(Some(auth.user.id)).await?;
let lends = state.db.pim_lends(auth.user.id).await?;
Ok(Json(PimOwnShares {
links: links.into_iter().map(feed_entry).collect(),
lends: lends
.into_iter()
.map(
|(collection_id, collection_name, kind, user_id, user_name, mode)| PimLend {
collection_id,
collection_name,
kind: wire_kind(kind),
share: PimShareInfo {
user_id,
user_name,
mode,
},
},
)
.collect(),
}))
}
/// GET {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}
pub async fn links(
State(state): State<Arc<AppState>>,
▾Mserver/src/db.rs
@@ -2177,8 +2177,12 @@ impl Db {
.collect()
}
/// Every public feed link with its collection and owner, for the admin.
pub async fn all_pim_links(&self) -> DbResult<Vec<PimLinkWithOwner>> {
/// Public feed links with their collection and owner: those of the
/// account `owner`, or every one for the admin.
pub async fn pim_links_with_owner(
&self,
owner: Option<i64>,
) -> DbResult<Vec<PimLinkWithOwner>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
"SELECT l.id, l.token, l.collection_id, l.busy_only, l.created_at, l.expires_at,
@@ -2188,9 +2192,10 @@ impl Db {
JOIN pim_collections c ON c.id = l.collection_id
JOIN principals p ON p.id = c.principal_id
JOIN users u ON u.id = p.user_id
WHERE ?1 IS NULL OR u.id = ?1
ORDER BY u.name COLLATE NOCASE, l.id DESC",
)?;
stmt.query_map([], |r| {
stmt.query_map([owner], |r| {
Ok(PimLinkWithOwner {
link: map_pim_link(r)?,
collection_name: r.get(7)?,
@@ -2203,6 +2208,35 @@ impl Db {
.collect()
}
/// The loans of the account `owner`'s collections:
/// `(collection id, name, kind, borrower id, borrower name, mode)`.
pub async fn pim_lends(
&self,
owner: i64,
) -> DbResult<Vec<(i64, String, PimKind, i64, String, PimShareMode)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
"SELECT c.id, coalesce(c.displayname, c.slug), c.kind, u.id, u.name, s.mode
FROM pim_shares s
JOIN pim_collections c ON c.id = s.collection_id
JOIN principals p ON p.id = c.principal_id
JOIN users u ON u.id = s.user_id
WHERE p.user_id = ?1
ORDER BY coalesce(c.displayname, c.slug) COLLATE NOCASE, u.name COLLATE NOCASE",
)?;
stmt.query_map([owner], |r| {
Ok((
r.get(0)?,
r.get(1)?,
PimKind::parse(&r.get::<_, String>(2)?),
r.get(3)?,
r.get(4)?,
r.get::<_, SqlShareMode>(5)?.0,
))
})?
.collect()
}
/// Revokes a feed link whoever made it.
pub async fn admin_delete_pim_link(&self, id: i64) -> DbResult<bool> {
let c = self.conn.lock().await;
▾Mserver/tests/api_pim_derived.rs
@@ -497,3 +497,40 @@ async fn admin_sees_and_revokes_feeds() {
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn own_shares_list_only_mine() {
let pim = Pim::new(Env::new().await).await;
let bob = login(&pim.env, "bob", PW).await;
let cal = pim.id("/pim/calendars/alice/default/").await;
let r = pim
.alice
.post_json(&format!("/api/pim/collections/{cal}/links"), &json!({}))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let r = pim
.alice
.post_json(
&format!("/api/pim/collections/{cal}/shares"),
&json!({"user": "bob", "mode": "rw"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let mine = pim.alice.get("/api/pim/shares").await;
assert_eq!(mine.status, StatusCode::OK);
let mine = mine.json();
assert_eq!(mine["links"].as_array().unwrap().len(), 1);
assert_eq!(mine["links"][0]["collection_id"], cal);
assert_eq!(mine["lends"].as_array().unwrap().len(), 1);
assert_eq!(mine["lends"][0]["collection_id"], cal);
assert_eq!(mine["lends"][0]["user_name"], "bob");
assert_eq!(mine["lends"][0]["mode"], "rw");
// The borrower sees neither alice's feed nor her loan as his own.
let theirs = bob.get("/api/pim/shares").await.json();
assert!(theirs["links"].as_array().unwrap().is_empty(), "{theirs}");
assert!(theirs["lends"].as_array().unwrap().is_empty(), "{theirs}");
let admin = pim.admin.get("/api/pim/shares").await.json();
assert!(admin["links"].as_array().unwrap().is_empty(), "{admin}");
}
▾Mweb/app.css
@@ -1655,6 +1655,38 @@ button:disabled {
flex-shrink: 0;
}
/* Phones: the name gets the full width, the buttons move below it. */
@media (max-width: 640px) {
.share-item {
flex-wrap: wrap;
}
.share-item-main,
.share-item-actions {
flex: 1 1 100%;
min-width: 0;
}
.shares-view .share-item-actions select {
flex: 1 1 auto;
min-width: 0;
}
.share-item-actions .btn {
white-space: nowrap;
}
}
/* A loan's mode select sits beside small buttons: same height. No
percentage cap: the actions box sizes to its content, so a cap would
leave a gap after the select. */
.shares-view .share-item-actions select {
max-width: none;
padding-top: 5px;
padding-bottom: 5px;
font-size: 12.5px;
}
/* Sections of the share management view: the viewer's own links and, for an
admin, one section per account. Native <details>/<summary>. */
@@ -3269,11 +3301,12 @@ body:has(.page-busy) {
transform: scaleX(-1);
}
/* Selects in the calendar and contacts views, their dialogs, and any form
field: the same height and colors as the inputs beside them, in both
themes. */
/* Selects in the calendar and contacts views, their dialogs, the shares
page, and any form field: the same height and colors as the inputs beside
them, in both themes. */
.pim-modal select,
.pim-view select,
.shares-view select,
select.field-input {
appearance: none;
padding: 10px 34px 10px 12px;
@@ -3290,6 +3323,7 @@ select.field-input {
.pim-modal select:focus,
.pim-view select:focus,
.shares-view select:focus,
select.field-input:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
▾Mweb/src/api.rs
@@ -27,13 +27,13 @@ use api_types::{
ADMIN_PIM_LINKS, ADMIN_ROOMS, CANDIDATES_SUFFIX, CreatePimCollection, CreatePimLink,
CreatePimShare, CreateRoom, EXPORT_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, OBJECTS_SUFFIX, P_FROM,
P_RECURRENCE_ID, P_TO, P_TZ, PHOTO_SUFFIX, PIM_COLLECTIONS, PIM_CONTACTS, PIM_IMPORT_NEW,
PIM_INSTANCES, PIM_INVITATIONS, SHARES_SUFFIX, UpdatePimCollection, UpdateRoom,
PIM_INSTANCES, PIM_INVITATIONS, PIM_SHARES, SHARES_SUFFIX, UpdatePimCollection, UpdateRoom,
};
pub use api_types::{
AdminPimLink, PimCollectionInfo, PimCollectionKind, PimContact, PimContactDetail,
PimEventDetail, PimImportNew, PimImportResult, PimInstance, PimInstances, PimInvitation,
PimLinkInfo, PimObjectDetail, PimReply, PimShareCandidate, PimShareInfo, PimShareMode,
RoomInfo, RoomKind,
PimLend, PimLinkInfo, PimObjectDetail, PimOwnShares, PimReply, PimShareCandidate, PimShareInfo,
PimShareMode, RoomInfo, RoomKind,
};
pub use api_types::{
AdminShare, AdminUser, AppPasswordInfo, AuthMode, Entry, Existing, FilesResp, LoginResp, Me,
@@ -1438,6 +1438,11 @@ pub fn delete_room(id: i64) -> impl std::future::Future<Output = Result<OkResp,
request("DELETE", format!("{ADMIN_ROOMS}/{id}"), None::<()>)
}
/// The signed-in user's own feed links and loans.
pub fn pim_own_shares() -> impl std::future::Future<Output = Result<PimOwnShares, ApiError>> {
request("GET", PIM_SHARES.to_string(), None::<()>)
}
/// Admin only: every public calendar and address book feed.
pub fn admin_pim_links() -> impl std::future::Future<Output = Result<Vec<AdminPimLink>, ApiError>> {
request("GET", ADMIN_PIM_LINKS.to_string(), None::<()>)
▾Mweb/src/i18n.rs
@@ -482,8 +482,7 @@ i18n_keys! {
PIM_DIRECTORY = "pim_directory" => "Directory",
PIM_EMAIL = "pim_email" => "Email",
PIM_EXPORT = "pim_export" => "Export",
PIM_FEEDS = "pim_feeds" => "Calendar and contact feeds",
PIM_FEEDS_HINT = "pim_feeds_hint" => "Every public calendar and address book link on this server.",
PIM_FEEDS_OF = "pim_feeds_of" => "Feeds of {}",
PIM_FILE = "pim_file" => "File",
PIM_FROM_ORGANIZER = "pim_from_organizer" => "From {}",
PIM_FROM_TIME = "pim_from_time" => "from {}",
@@ -538,7 +537,6 @@ i18n_keys! {
PIM_NO_CONTACTS = "pim_no_contacts" => "No contacts yet. Add some with a contacts app or an import.",
PIM_NO_EVENTS_AGENDA = "pim_no_events_agenda" => "No events in the next 7 days.",
PIM_NO_EVENTS_DAY = "pim_no_events_day" => "No events on this day.",
PIM_NO_FEEDS = "pim_no_feeds" => "No public feeds.",
PIM_NO_INVITATIONS = "pim_no_invitations" => "No open invitations.",
PIM_NO_LINKS = "pim_no_links" => "No public links.",
PIM_NO_MATCH = "pim_no_match" => "No contact matches.",
@@ -550,6 +548,10 @@ i18n_keys! {
PIM_NOTE = "pim_note" => "Note",
PIM_OPTIONAL = "pim_optional" => "optional",
PIM_ORGANIZER = "pim_organizer" => "Organizer",
PIM_OWN_EMPTY = "pim_own_empty" => "No public links, and nothing shared with other users.",
PIM_OWN_HINT = "pim_own_hint" => "Public links to your calendars and address books, and what you share with other users. Each collection's settings manage them too.",
PIM_OWN_LENDS = "pim_own_lends" => "Shared with other users",
PIM_OWN_LINKS = "pim_own_links" => "Your public links",
PIM_PASSWORD_HINT = "pim_password_hint" => "Use an app password rather than your account password.",
PIM_PHONE = "pim_phone" => "Phone",
PIM_PICK_CONTACT = "pim_pick_contact" => "Choose a contact to see the details.",
@@ -1272,11 +1274,7 @@ const DE: &[(&str, &str)] = &[
("pim_directory", "Verzeichnis"),
("pim_email", "E-Mail"),
("pim_export", "Exportieren"),
("pim_feeds", "Kalender- und Kontakt-Feeds"),
(
"pim_feeds_hint",
"Jeder öffentliche Kalender- und Adressbuch-Link auf diesem Server.",
),
("pim_feeds_of", "Feeds von {}"),
("pim_file", "Datei"),
("pim_from_organizer", "Von {}"),
("pim_from_time", "ab {}"),
@@ -1379,7 +1377,6 @@ const DE: &[(&str, &str)] = &[
"Keine Termine in den nächsten 7 Tagen.",
),
("pim_no_events_day", "Keine Termine an diesem Tag."),
("pim_no_feeds", "Keine öffentlichen Feeds."),
("pim_no_invitations", "Keine offenen Einladungen."),
("pim_no_links", "Keine öffentlichen Links."),
("pim_no_match", "Kein Kontakt passt."),
@@ -1391,6 +1388,16 @@ const DE: &[(&str, &str)] = &[
("pim_note", "Notiz"),
("pim_optional", "optional"),
("pim_organizer", "Organisator"),
(
"pim_own_empty",
"Keine öffentlichen Links und nichts mit anderen Benutzern geteilt.",
),
(
"pim_own_hint",
"Öffentliche Links zu Ihren Kalendern und Adressbüchern und was Sie mit anderen Benutzern teilen. Sie lassen sich auch in den Einstellungen der Sammlung verwalten.",
),
("pim_own_lends", "Mit anderen Benutzern geteilt"),
("pim_own_links", "Ihre öffentlichen Links"),
(
"pim_password_hint",
"Nutzen Sie ein App-Passwort statt Ihres Kontopassworts.",
@@ -2236,11 +2243,7 @@ const FR: &[(&str, &str)] = &[
("pim_directory", "Annuaire"),
("pim_email", "E-mail"),
("pim_export", "Exporter"),
("pim_feeds", "Flux de calendriers et de contacts"),
(
"pim_feeds_hint",
"Chaque lien public de calendrier et de carnet d'adresses sur ce serveur.",
),
("pim_feeds_of", "Flux de {}"),
("pim_file", "Fichier"),
("pim_from_organizer", "De {}"),
("pim_from_time", "à partir de {}"),
@@ -2349,7 +2352,6 @@ const FR: &[(&str, &str)] = &[
"Aucun événement dans les 7 prochains jours.",
),
("pim_no_events_day", "Aucun événement ce jour-là."),
("pim_no_feeds", "Aucun flux public."),
("pim_no_invitations", "Aucune invitation en attente."),
("pim_no_links", "Aucun lien public."),
("pim_no_match", "Aucun contact ne correspond."),
@@ -2361,6 +2363,16 @@ const FR: &[(&str, &str)] = &[
("pim_note", "Note"),
("pim_optional", "facultatif"),
("pim_organizer", "Organisateur"),
(
"pim_own_empty",
"Aucun lien public, et rien de partagé avec d'autres utilisateurs.",
),
(
"pim_own_hint",
"Liens publics vers vos calendriers et carnets d'adresses, et ce que vous partagez avec d'autres utilisateurs. Les réglages de chaque collection les gèrent aussi.",
),
("pim_own_lends", "Partagé avec d'autres utilisateurs"),
("pim_own_links", "Vos liens publics"),
(
"pim_password_hint",
"Utilisez un mot de passe d'application plutôt que celui de votre compte.",
▾Mweb/src/views/pim.rs
@@ -105,7 +105,7 @@ pub fn writable(c: &PimCollectionInfo) -> bool {
!c.generated && c.mode != Some(PimShareMode::Ro)
}
fn mode_label(m: PimShareMode) -> &'static str {
pub fn mode_label(m: PimShareMode) -> &'static str {
match m {
PimShareMode::Ro => i18n::t(k::PIM_MODE_RO),
PimShareMode::Rw => i18n::t(k::PIM_MODE_RW),
@@ -113,6 +113,13 @@ fn mode_label(m: PimShareMode) -> &'static str {
}
}
/// `webcal://` opens the calendar app; the transport stays HTTP(S).
pub fn webcal(url: &str) -> Option<String> {
url.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.map(|rest| format!("webcal://{rest}"))
}
/// The browser's IANA time zone, for dates the server resolves.
pub fn browser_tz() -> String {
let opts = js_sys::Intl::DateTimeFormat::new(&js_sys::Array::new(), &js_sys::Object::new())
@@ -899,11 +906,7 @@ fn LinksSection(info: PimCollectionInfo) -> impl IntoView {
};
let link_row = move |l: PimLinkInfo| {
let full = server_url(&l.path);
// `webcal://` opens the calendar app; the transport stays HTTP(S).
let webcal = full
.strip_prefix("https://")
.or_else(|| full.strip_prefix("http://"))
.map(|rest| format!("webcal://{rest}"));
let webcal = webcal(&full);
let mut meta = Vec::new();
if calendar {
meta.push(if l.busy_only {
▾Mweb/src/views/shares.rs
@@ -7,7 +7,9 @@ use wasm_bindgen_futures::spawn_local;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use crate::api::{self, AdminPimLink, AdminShare, Me, ShareInfo, UserInfo};
use crate::api::{
self, AdminPimLink, AdminShare, Me, PimLend, PimOwnShares, PimShareMode, ShareInfo, UserInfo,
};
use crate::components::icon::icon_svg;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
@@ -15,8 +17,9 @@ use crate::i18n;
use crate::icons::IconName;
use crate::router::{dav_share_url, share_url};
use crate::util::copy_to_clipboard;
use crate::util::format_date;
use crate::util::{format_date, local_date_time};
use crate::views::dialogs::ConfirmDialog;
use crate::views::pim::{mode_label, webcal};
// ---------------------------------------------------------------------------
// Expiry helpers (compute the RFC 3339 expiry client-side)
@@ -708,7 +711,7 @@ pub fn SharesView(me: ReadSignal<Option<Me>>) -> impl IntoView {
}
}}
{move || admin().then(|| view! { <AdminFeeds my_id=my_id()/> })}
{move || view! { <PimShares admin=admin() my_id=my_id()/> }}
// Delete-all confirmation, for one account or for everything.
{move || {
@@ -734,33 +737,49 @@ pub fn SharesView(me: ReadSignal<Option<Me>>) -> impl IntoView {
}
}
/// Admin only: every public calendar and address book feed on the server,
/// grouped by owner like the file shares above. Owners manage their own in
/// the calendar and contact views.
/// Calendar and address book feeds and loans, grouped like the file shares
/// above. Everyone sees their own; an admin also sees everyone else's feeds.
/// Loans between other users stay private: they expose nothing publicly.
#[component]
fn AdminFeeds(my_id: i64) -> impl IntoView {
fn PimShares(admin: bool, my_id: i64) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (feeds, set_feeds) = signal(Option::<Vec<AdminPimLink>>::None);
// Open sections by owner, kept across reloads like the file shares'.
let (own, set_own) = signal(Option::<PimOwnShares>::None);
let (others, set_others) = signal(Vec::<AdminPimLink>::new());
// Open sections, kept across reloads like the file shares'. Own groups
// use the ids below, other owners their account id.
let (open, set_open) = signal(HashMap::<i64, bool>::new());
const OWN_LINKS: i64 = -1;
const OWN_LENDS: i64 = -2;
let load = move || {
spawn_local(async move {
match api::admin_pim_links().await {
match api::pim_own_shares().await {
Ok(v) => {
let _ = set_feeds.try_set(Some(v));
let _ = set_own.try_set(Some(v));
}
Err(e) => show_error(toast, e.to_string()),
}
if admin && let Ok(v) = api::admin_pim_links().await {
let v = v.into_iter().filter(|f| f.owner_id != my_id).collect();
let _ = set_others.try_set(v);
}
});
};
load();
let row = move |f: &AdminPimLink| {
let glyph = |kind: api::PimCollectionKind| match kind {
api::PimCollectionKind::Calendar => IconName::Calendar,
api::PimCollectionKind::Addressbook => IconName::Contacts,
};
// Own feeds end through the collection's route, other owners' through
// the admin route.
let feed_row = move |f: &AdminPimLink| {
let url = crate::router::server_url(&f.link.path);
let copy = url.clone();
let id = f.link.id;
let calendar = f.kind == api::PimCollectionKind::Calendar;
let subscribe = calendar.then(|| webcal(&url)).flatten();
let (id, cid, mine) = (f.link.id, f.collection_id, f.owner_id == my_id);
let mut meta = Vec::new();
// Address book feeds have no detail level.
if f.kind == api_types::PimCollectionKind::Calendar {
if calendar {
meta.push(if f.link.busy_only {
i18n::t(i18n::k::PIM_LINK_BUSY).to_string()
} else {
@@ -768,24 +787,20 @@ fn AdminFeeds(my_id: i64) -> impl IntoView {
});
}
if let Some(e) = &f.link.expires_at {
meta.push(i18n::t_fmt(i18n::k::META_EXPIRES, &format_date(e)));
meta.push(i18n::t_fmt(i18n::k::META_EXPIRES, &local_date_time(e)));
}
meta.push(i18n::t_fmt(
i18n::k::META_CREATED,
&format_date(&f.link.created_at),
&local_date_time(&f.link.created_at),
));
if f.link.has_password {
meta.push(i18n::t(i18n::k::SHARE_PROTECTED).to_string());
}
let glyph = match f.kind {
api::PimCollectionKind::Calendar => IconName::Calendar,
api::PimCollectionKind::Addressbook => IconName::Contacts,
};
view! {
<div class="share-item">
<div class="share-item-main">
<div class="share-item-name">
{icon_svg(glyph, "ic-row")}
{icon_svg(glyph(f.kind), "ic-row")}
<span>{f.collection_name.clone()}</span>
</div>
<div class="share-item-meta muted">{meta.join(" · ")}</div>
@@ -798,17 +813,23 @@ fn AdminFeeds(my_id: i64) -> impl IntoView {
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(i18n::k::COPY)}
</button>
{subscribe.map(|w| view! {
<a class="btn btn-sm" href=w>{i18n::t(i18n::k::PIM_SUBSCRIBE)}</a>
})}
<button
class="btn btn-sm btn-danger"
on:click=move |_| {
spawn_local(async move {
match api::admin_delete_pim_link(id).await {
Ok(_) => {
show(toast, i18n::t(i18n::k::PIM_LINK_REVOKED).to_string());
load();
}
let done = if mine {
api::pim_delete_link(cid, id).await
} else {
api::admin_delete_pim_link(id).await
};
match done {
Ok(_) => show(toast, i18n::t(i18n::k::PIM_LINK_REVOKED).to_string()),
Err(e) => show_error(toast, e.to_string()),
}
load();
});
}
>
@@ -818,65 +839,132 @@ fn AdminFeeds(my_id: i64) -> impl IntoView {
</div>
</div>
}
.into_any()
};
let lend_row = move |l: &PimLend| {
let (cid, uid, who) = (l.collection_id, l.share.user_id, l.share.user_name.clone());
let modes: &[PimShareMode] = match l.kind {
api::PimCollectionKind::Calendar => {
&[PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule]
}
api::PimCollectionKind::Addressbook => &[PimShareMode::Ro, PimShareMode::Rw],
};
let current = l.share.mode;
view! {
<div class="share-item">
<div class="share-item-main">
<div class="share-item-name">
{icon_svg(glyph(l.kind), "ic-row")}
<span>{l.collection_name.clone()}</span>
</div>
<div class="share-item-meta muted">{l.share.user_name.clone()}</div>
</div>
<div class="share-item-actions">
<select
class="pim-mode"
aria-label=i18n::tr(i18n::k::PIM_ACCESS)
on:change=move |ev| {
let Some(m) = PimShareMode::from_wire(&event_target_value(&ev)) else {
return;
};
let who = who.clone();
spawn_local(async move {
if let Err(e) = api::pim_share(cid, &who, m).await {
show_error(toast, e.to_string());
}
// Also on failure, so the select shows the real mode.
load();
});
}
>
{modes
.iter()
.map(|&m| view! {
<option value=m.as_str() selected=m == current>{mode_label(m)}</option>
})
.collect::<Vec<_>>()}
</select>
<button
class="btn btn-sm btn-danger"
on:click=move |_| {
spawn_local(async move {
match api::pim_unshare(cid, uid).await {
Ok(_) => show(toast, i18n::t(i18n::k::PIM_UNSHARED).to_string()),
Err(e) => show_error(toast, e.to_string()),
}
load();
});
}
>
{icon_svg(IconName::Delete, "ic-btn")}
{i18n::t(i18n::k::PIM_UNSHARE)}
</button>
</div>
</div>
}
.into_any()
};
let group = move |gid: i64, title: String, warn: bool, rows: Vec<AnyView>| {
let n = rows.len();
view! {
<details
class="share-group"
prop:open=move || open.with(|m| m.get(&gid).copied().unwrap_or(gid < 0))
on:toggle=move |ev: web_sys::Event| {
let on = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlDetailsElement>().ok())
.is_some_and(|d| d.open());
set_open.update(|m| {
m.insert(gid, on);
});
}
>
<summary class="share-group-head">
{icon_svg(IconName::ChevronRight, "share-caret")}
<span class="share-group-title">{title}</span>
{warn.then(|| view! {
<span class="share-group-warn">{i18n::tr(i18n::k::SHARE_OWNER_DISABLED)}</span>
})}
<span class="share-group-count muted">{n}</span>
</summary>
<div class="share-list">{rows}</div>
</details>
}
};
view! {
<div class="view-header feeds-header">
<h3 class="admin-section-title">{i18n::tr(i18n::k::PIM_FEEDS)}</h3>
<p class="muted view-hint">{i18n::tr(i18n::k::PIM_FEEDS_HINT)}</p>
<h3 class="admin-section-title">{i18n::tr(i18n::k::PIM_NAV)}</h3>
<p class="muted view-hint">{i18n::tr(i18n::k::PIM_OWN_HINT)}</p>
</div>
{move || match feeds.get() {
None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }.into_any(),
Some(list) if list.is_empty() => {
view! { <p class="muted">{i18n::tr(i18n::k::PIM_NO_FEEDS)}</p> }.into_any()
{move || {
let Some(own) = own.get() else {
return view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }.into_any();
};
let others = others.get();
if own.links.is_empty() && own.lends.is_empty() && others.is_empty() {
return view! { <p class="muted">{i18n::tr(i18n::k::PIM_OWN_EMPTY)}</p> }.into_any();
}
Some(list) => {
// The viewer's own first, then by owner name.
let mut groups: BTreeMap<(bool, String, i64), Vec<AdminPimLink>> = BTreeMap::new();
for f in list {
groups
.entry((f.owner_id != my_id, f.owner_name.to_lowercase(), f.owner_id))
.or_default()
.push(f);
}
let sections = groups
.into_iter()
.map(|((_, _, gid), items)| {
let own = gid == my_id;
let name = items[0].owner_name.clone();
let inactive = !items[0].owner_active;
let n = items.len();
let rows = items.iter().map(row).collect::<Vec<_>>();
view! {
<details
class="share-group"
prop:open=move || open.with(|m| m.get(&gid).copied().unwrap_or(own))
on:toggle=move |ev: web_sys::Event| {
let on = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlDetailsElement>().ok())
.is_some_and(|d| d.open());
set_open.update(|m| {
m.insert(gid, on);
});
}
>
<summary class="share-group-head">
{icon_svg(IconName::ChevronRight, "share-caret")}
<span class="share-group-title">
{move || if own { i18n::t(i18n::k::YOUR_SHARES).to_string() } else { name.clone() }}
</span>
{inactive.then(|| view! {
<span class="share-group-warn">{i18n::tr(i18n::k::SHARE_OWNER_DISABLED)}</span>
})}
<span class="share-group-count muted">{n}</span>
</summary>
<div class="share-list">{rows}</div>
</details>
}
})
.collect::<Vec<_>>();
view! { <div class="share-groups">{sections}</div> }.into_any()
let mut sections = Vec::new();
if !own.links.is_empty() {
let rows = own.links.iter().map(feed_row).collect();
sections.push(group(OWN_LINKS, i18n::t(i18n::k::PIM_OWN_LINKS).to_string(), false, rows));
}
if !own.lends.is_empty() {
let rows = own.lends.iter().map(lend_row).collect();
sections.push(group(OWN_LENDS, i18n::t(i18n::k::PIM_OWN_LENDS).to_string(), false, rows));
}
// Other owners' feeds, by name, collapsed.
let mut by_owner: BTreeMap<(String, i64), Vec<AdminPimLink>> = BTreeMap::new();
for f in others {
by_owner.entry((f.owner_name.to_lowercase(), f.owner_id)).or_default().push(f);
}
for ((_, gid), items) in by_owner {
let title = i18n::t_fmt(i18n::k::PIM_FEEDS_OF, &items[0].owner_name);
let rows = items.iter().map(feed_row).collect();
sections.push(group(gid, title, !items[0].owner_active, rows));
}
view! { <div class="share-groups">{sections}</div> }.into_any()
}}
}
}