diff --git a/core/src/sync/client.rs b/core/src/sync/client.rs index 62015b8..39809a9 100644 --- a/core/src/sync/client.rs +++ b/core/src/sync/client.rs @@ -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, + #[serde(default)] + pub groups: Vec, +} + +/// 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, + #[serde(default)] + pub group: Option, pub permission: String, #[serde(default)] pub created_at: Option, } -#[derive(Deserialize)] -struct MembersReply { - members: Vec, +/// 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( .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, String> { +/// Everyone on the instance but this account, and every group. +pub async fn directory(base_url: &str, token: &str) -> Result { 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, 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 diff --git a/core/src/sync/sharing.rs b/core/src/sync/sharing.rs index 9755b1f..8271e73 100644 --- a/core/src/sync/sharing.rs +++ b/core/src/sync/sharing.rs @@ -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, String> { +pub async fn directory(db: &Db) -> Result { let (url, token) = link(db)?; client::directory(&url, &token).await } @@ -53,11 +53,11 @@ pub async fn list(db: &Db, note_id: &str) -> Result, String> { pub async fn share( db: &Db, note_id: &str, - user_id: &str, + target: &ShareTarget, permission: &str, ) -> Result, 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) -> Result { // `sharing::NEEDS_SERVER`, which the dialog shows as it is. #[tauri::command] -pub async fn shares_directory(db: State<'_, Db>) -> Result, String> { +pub async fn shares_directory(db: State<'_, Db>) -> Result { sharing::directory(&db).await } @@ -202,14 +202,21 @@ pub async fn shares_list(note_id: String, db: State<'_, Db>) -> Result, + group_id: Option, permission: String, db: State<'_, Db>, ) -> Result, String> { - sharing::share(&db, ¬e_id, &user_id, &permission).await + let target = match (user_id, group_id) { + (Some(id), None) => ShareTarget::Member(id), + (None, Some(id)) => ShareTarget::Group(id), + _ => return Err("Choose someone or a group to share with.".to_string()), + }; + sharing::share(&db, ¬e_id, &target, &permission).await } #[tauri::command] diff --git a/frontend/src/adapters/local.ts b/frontend/src/adapters/local.ts index 851cb34..5034076 100644 --- a/frontend/src/adapters/local.ts +++ b/frontend/src/adapters/local.ts @@ -16,7 +16,7 @@ import type { Device } from "../stores/devices"; import type { TitleEntry } from "../stores/titles"; import type { User } from "../stores/session"; import type { PublicConfig } from "../stores/config"; -import type { DeviceToken, ImportResult, Member, NoteShare, Repo } from "./repo"; +import type { DeviceToken, Directory, ImportResult, NoteShare, Repo } from "./repo"; const NEEDS_SERVER = "That's not available offline — connect a server to use it."; @@ -95,9 +95,15 @@ export const local: Repo = { // Sharing is between accounts on a server, so these go to the linked one (#5175). // Unlinked, each rejects with the core's explanation, which the dialog shows. shares: { - directory: () => invoke("shares_directory"), + directory: () => invoke("shares_directory"), list: (noteId) => invoke("shares_list", { noteId }), - share: (noteId, userId, permission) => invoke("shares_share", { noteId, userId, permission }), + share: (noteId, target, permission) => + invoke("shares_share", { + noteId, + userId: "user_id" in target ? target.user_id : null, + groupId: "group_id" in target ? target.group_id : null, + permission, + }), unshare: (noteId, shareId) => invoke("shares_unshare", { noteId, shareId }), }, }; diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index 0705674..4efed3e 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -55,11 +55,29 @@ export interface Member { email: string; } +/** A group the admin made, as the directory shows it (#5177). */ +export interface ShareGroup { + id: string; + name: string; + member_count: number; +} + +/** Everyone a note can be shared with: the other people, and every group. */ +export interface Directory { + members: Member[]; + groups: ShareGroup[]; +} + +/** Who a share goes to: one person, or a group (whoever is in it at the time). */ +export type ShareTarget = { user_id: string } | { group_id: string }; + export type SharePermission = "view" | "edit"; +/** One share of a note: exactly one of `member` and `group` is set. */ export interface NoteShare { id: string; - member: Member; + member: Member | null; + group: ShareGroup | null; permission: SharePermission; created_at: string | null; } @@ -135,11 +153,11 @@ export interface NotesRepo { } export interface SharesRepo { - /** Everyone on the instance but you. */ - directory(): Promise; + /** Everyone on the instance but you, and every group. */ + directory(): Promise; list(noteId: string): Promise; - /** Share with one member, or change their permission. Answers the note's shares. */ - share(noteId: string, userId: string, permission: SharePermission): Promise; + /** Share with a person or a group, or change their permission. Answers the note's shares. */ + share(noteId: string, target: ShareTarget, permission: SharePermission): Promise; unshare(noteId: string, shareId: string): Promise; } diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index d3fd680..72055f9 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -14,8 +14,8 @@ import type { User } from "../stores/session"; import type { PublicConfig } from "../stores/config"; import type { DeviceToken, + Directory, ImportResult, - Member, NoteChanges, NoteShare, NoteCreateInput, @@ -120,10 +120,10 @@ export const rest: Repo = { }, shares: { - directory: async () => (await api.get<{ members: Member[] }>("/api/users/directory")).members, + directory: () => api.get("/api/users/directory"), list: async (noteId) => (await api.get<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`)).shares, - share: async (noteId, userId, permission) => - (await api.post<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`, { user_id: userId, permission })).shares, + share: async (noteId, target, permission) => + (await api.post<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`, { ...target, permission })).shares, unshare: async (noteId, shareId) => (await api.del<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares/${shareId}`)).shares, }, diff --git a/frontend/src/components/GroupList.vue b/frontend/src/components/GroupList.vue new file mode 100644 index 0000000..3e2c3ed --- /dev/null +++ b/frontend/src/components/GroupList.vue @@ -0,0 +1,228 @@ + + + diff --git a/frontend/src/components/ShareDialog.vue b/frontend/src/components/ShareDialog.vue index 62119d8..4e25617 100644 --- a/frontend/src/components/ShareDialog.vue +++ b/frontend/src/components/ShareDialog.vue @@ -1,15 +1,16 @@