web, core, desktop: share a note with a group, and Settings → Groups
The Share dialog lists people and groups in one picker and shows a group share as its name and member count. Settings gains a Groups section for the admin: create, rename, delete, and add or remove people. The core client reads the directory's groups and group shares (ShareTarget: a member or a group); the desktop command takes user_id or group_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+73
-12
@@ -439,19 +439,59 @@ pub struct Member {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// One person a note is shared with, and at what level (`view` or `edit`).
|
||||
/// A group the admin made, which a note can be shared with (#5177). Sharing with it
|
||||
/// reaches whoever is in it at the time.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ShareGroup {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub member_count: u32,
|
||||
}
|
||||
|
||||
/// Everyone a note can be shared with: the other people on the instance, and every
|
||||
/// group. `groups` defaults empty for a server from before groups.
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct Directory {
|
||||
#[serde(default)]
|
||||
pub members: Vec<Member>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<ShareGroup>,
|
||||
}
|
||||
|
||||
/// One share of a note, at `view` or `edit`: to a person (`member`) or to a group
|
||||
/// (`group`), never both.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct NoteShare {
|
||||
pub id: String,
|
||||
pub member: Member,
|
||||
#[serde(default)]
|
||||
pub member: Option<Member>,
|
||||
#[serde(default)]
|
||||
pub group: Option<ShareGroup>,
|
||||
pub permission: String,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MembersReply {
|
||||
members: Vec<Member>,
|
||||
/// Who a new share goes to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ShareTarget {
|
||||
Member(String),
|
||||
Group(String),
|
||||
}
|
||||
|
||||
impl ShareTarget {
|
||||
fn body(&self, permission: &str) -> serde_json::Value {
|
||||
match self {
|
||||
ShareTarget::Member(id) => {
|
||||
serde_json::json!({ "user_id": id, "permission": permission })
|
||||
}
|
||||
ShareTarget::Group(id) => {
|
||||
serde_json::json!({ "group_id": id, "permission": permission })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -493,11 +533,10 @@ async fn read_reply<T: serde::de::DeserializeOwned>(
|
||||
.map_err(|e| format!("Couldn't read the reply from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
/// Everyone on the instance but this account.
|
||||
pub async fn directory(base_url: &str, token: &str) -> Result<Vec<Member>, String> {
|
||||
/// Everyone on the instance but this account, and every group.
|
||||
pub async fn directory(base_url: &str, token: &str) -> Result<Directory, String> {
|
||||
let url = format!("{base_url}/api/users/directory");
|
||||
let reply: MembersReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?;
|
||||
Ok(reply.members)
|
||||
read_reply(base_url, prepare(http()?.get(url), Some(token))).await
|
||||
}
|
||||
|
||||
pub async fn list_shares(
|
||||
@@ -510,16 +549,17 @@ pub async fn list_shares(
|
||||
Ok(reply.shares)
|
||||
}
|
||||
|
||||
/// Share with one member, or change their permission. Answers the note's shares.
|
||||
/// Share with a person or a group, or change their permission. Answers the note's
|
||||
/// shares.
|
||||
pub async fn share_note(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
note_id: &str,
|
||||
user_id: &str,
|
||||
target: &ShareTarget,
|
||||
permission: &str,
|
||||
) -> Result<Vec<NoteShare>, String> {
|
||||
let url = format!("{base_url}/api/notes/{note_id}/shares");
|
||||
let body = serde_json::json!({ "user_id": user_id, "permission": permission });
|
||||
let body = target.body(permission);
|
||||
let reply: SharesReply = read_reply(
|
||||
base_url,
|
||||
prepare(http()?.post(url), Some(token)).json(&body),
|
||||
@@ -583,6 +623,27 @@ fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_share_names_a_person_or_a_group() {
|
||||
assert_eq!(
|
||||
ShareTarget::Member("u1".into()).body("view"),
|
||||
serde_json::json!({ "user_id": "u1", "permission": "view" })
|
||||
);
|
||||
assert_eq!(
|
||||
ShareTarget::Group("g1".into()).body("edit"),
|
||||
serde_json::json!({ "group_id": "g1", "permission": "edit" })
|
||||
);
|
||||
let raw = r#"{"id":"s2","member":null,
|
||||
"group":{"id":"g1","name":"Family","member_count":3},
|
||||
"permission":"edit"}"#;
|
||||
let share: NoteShare = serde_json::from_str(raw).expect("a group share reads");
|
||||
assert!(share.member.is_none());
|
||||
assert_eq!(share.group.expect("group").member_count, 3);
|
||||
// A server from before groups sends no `groups` at all.
|
||||
let older: Directory = serde_json::from_str(r#"{"members":[]}"#).expect("directory");
|
||||
assert!(older.groups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_join_without_doubling_slashes() {
|
||||
// normalize_base_url has already stripped any trailing slash, so plain
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::client::{self, Member, NoteShare};
|
||||
use super::client::{self, Directory, NoteShare, ShareTarget};
|
||||
use super::state;
|
||||
use crate::local::Db;
|
||||
|
||||
@@ -38,7 +38,7 @@ fn mark_shared(db: &Db, note_id: &str, shares: &[NoteShare]) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn directory(db: &Db) -> Result<Vec<Member>, String> {
|
||||
pub async fn directory(db: &Db) -> Result<Directory, String> {
|
||||
let (url, token) = link(db)?;
|
||||
client::directory(&url, &token).await
|
||||
}
|
||||
@@ -53,11 +53,11 @@ pub async fn list(db: &Db, note_id: &str) -> Result<Vec<NoteShare>, String> {
|
||||
pub async fn share(
|
||||
db: &Db,
|
||||
note_id: &str,
|
||||
user_id: &str,
|
||||
target: &ShareTarget,
|
||||
permission: &str,
|
||||
) -> Result<Vec<NoteShare>, String> {
|
||||
let (url, token) = link(db)?;
|
||||
let shares = client::share_note(&url, &token, note_id, user_id, permission).await?;
|
||||
let shares = client::share_note(&url, &token, note_id, target, permission).await?;
|
||||
mark_shared(db, note_id, &shares)?;
|
||||
Ok(shares)
|
||||
}
|
||||
@@ -73,6 +73,7 @@ pub async fn unshare(db: &Db, note_id: &str, share_id: &str) -> Result<Vec<NoteS
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::local::schema;
|
||||
use crate::sync::client::Member;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -101,11 +102,12 @@ mod tests {
|
||||
}
|
||||
let one = NoteShare {
|
||||
id: "s1".into(),
|
||||
member: Member {
|
||||
member: Some(Member {
|
||||
id: "u2".into(),
|
||||
display_name: "Sam".into(),
|
||||
email: "sam@example.test".into(),
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
permission: "view".into(),
|
||||
created_at: None,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user