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:
2026-10-07 18:25:16 -04:00
co-authored by Claude Opus 5.5
parent 5f3cfe8bb7
commit 442cca4398
9 changed files with 413 additions and 57 deletions
+73 -12
View File
@@ -439,19 +439,59 @@ pub struct Member {
pub email: String, 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)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct NoteShare { pub struct NoteShare {
pub id: String, pub id: String,
pub member: Member, #[serde(default)]
pub member: Option<Member>,
#[serde(default)]
pub group: Option<ShareGroup>,
pub permission: String, pub permission: String,
#[serde(default)] #[serde(default)]
pub created_at: Option<String>, pub created_at: Option<String>,
} }
#[derive(Deserialize)] /// Who a new share goes to.
struct MembersReply { #[derive(Debug, Clone, PartialEq, Eq)]
members: Vec<Member>, 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)] #[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}")) .map_err(|e| format!("Couldn't read the reply from {base_url}: {e}"))
} }
/// Everyone on the instance but this account. /// Everyone on the instance but this account, and every group.
pub async fn directory(base_url: &str, token: &str) -> Result<Vec<Member>, String> { pub async fn directory(base_url: &str, token: &str) -> Result<Directory, String> {
let url = format!("{base_url}/api/users/directory"); let url = format!("{base_url}/api/users/directory");
let reply: MembersReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?; read_reply(base_url, prepare(http()?.get(url), Some(token))).await
Ok(reply.members)
} }
pub async fn list_shares( pub async fn list_shares(
@@ -510,16 +549,17 @@ pub async fn list_shares(
Ok(reply.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( pub async fn share_note(
base_url: &str, base_url: &str,
token: &str, token: &str,
note_id: &str, note_id: &str,
user_id: &str, target: &ShareTarget,
permission: &str, permission: &str,
) -> Result<Vec<NoteShare>, String> { ) -> Result<Vec<NoteShare>, String> {
let url = format!("{base_url}/api/notes/{note_id}/shares"); 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( let reply: SharesReply = read_reply(
base_url, base_url,
prepare(http()?.post(url), Some(token)).json(&body), 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 { mod tests {
use super::*; 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] #[test]
fn urls_join_without_doubling_slashes() { fn urls_join_without_doubling_slashes() {
// normalize_base_url has already stripped any trailing slash, so plain // normalize_base_url has already stripped any trailing slash, so plain
+8 -6
View File
@@ -8,7 +8,7 @@
use rusqlite::params; use rusqlite::params;
use super::client::{self, Member, NoteShare}; use super::client::{self, Directory, NoteShare, ShareTarget};
use super::state; use super::state;
use crate::local::Db; use crate::local::Db;
@@ -38,7 +38,7 @@ fn mark_shared(db: &Db, note_id: &str, shares: &[NoteShare]) -> Result<(), Strin
Ok(()) 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)?; let (url, token) = link(db)?;
client::directory(&url, &token).await 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( pub async fn share(
db: &Db, db: &Db,
note_id: &str, note_id: &str,
user_id: &str, target: &ShareTarget,
permission: &str, permission: &str,
) -> Result<Vec<NoteShare>, String> { ) -> Result<Vec<NoteShare>, String> {
let (url, token) = link(db)?; 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)?; mark_shared(db, note_id, &shares)?;
Ok(shares) Ok(shares)
} }
@@ -73,6 +73,7 @@ pub async fn unshare(db: &Db, note_id: &str, share_id: &str) -> Result<Vec<NoteS
mod tests { mod tests {
use super::*; use super::*;
use crate::local::schema; use crate::local::schema;
use crate::sync::client::Member;
use rusqlite::Connection; use rusqlite::Connection;
use std::sync::Mutex; use std::sync::Mutex;
@@ -101,11 +102,12 @@ mod tests {
} }
let one = NoteShare { let one = NoteShare {
id: "s1".into(), id: "s1".into(),
member: Member { member: Some(Member {
id: "u2".into(), id: "u2".into(),
display_name: "Sam".into(), display_name: "Sam".into(),
email: "sam@example.test".into(), email: "sam@example.test".into(),
}, }),
group: None,
permission: "view".into(), permission: "view".into(),
created_at: None, created_at: None,
}; };
+11 -4
View File
@@ -8,7 +8,7 @@ use tauri::{AppHandle, State};
use inkwell_core::local::Db; use inkwell_core::local::Db;
use inkwell_core::sync::client::{self, Identity, ProbeResult}; use inkwell_core::sync::client::{self, Identity, ProbeResult};
use inkwell_core::sync::client::{Member, NoteShare}; use inkwell_core::sync::client::{Directory, NoteShare, ShareTarget};
use inkwell_core::sync::compat::Compatibility; use inkwell_core::sync::compat::Compatibility;
use inkwell_core::sync::engine; use inkwell_core::sync::engine;
use inkwell_core::sync::push; use inkwell_core::sync::push;
@@ -193,7 +193,7 @@ pub fn sync_has_pending(db: State<'_, Db>) -> Result<bool, String> {
// `sharing::NEEDS_SERVER`, which the dialog shows as it is. // `sharing::NEEDS_SERVER`, which the dialog shows as it is.
#[tauri::command] #[tauri::command]
pub async fn shares_directory(db: State<'_, Db>) -> Result<Vec<Member>, String> { pub async fn shares_directory(db: State<'_, Db>) -> Result<Directory, String> {
sharing::directory(&db).await sharing::directory(&db).await
} }
@@ -202,14 +202,21 @@ pub async fn shares_list(note_id: String, db: State<'_, Db>) -> Result<Vec<NoteS
sharing::list(&db, &note_id).await sharing::list(&db, &note_id).await
} }
/// Share with a person (`user_id`) or a group (`group_id`, #5177): exactly one.
#[tauri::command] #[tauri::command]
pub async fn shares_share( pub async fn shares_share(
note_id: String, note_id: String,
user_id: String, user_id: Option<String>,
group_id: Option<String>,
permission: String, permission: String,
db: State<'_, Db>, db: State<'_, Db>,
) -> Result<Vec<NoteShare>, String> { ) -> Result<Vec<NoteShare>, String> {
sharing::share(&db, &note_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, &note_id, &target, &permission).await
} }
#[tauri::command] #[tauri::command]
+9 -3
View File
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles"; import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session"; import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config"; 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."; 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). // 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. // Unlinked, each rejects with the core's explanation, which the dialog shows.
shares: { shares: {
directory: () => invoke<Member[]>("shares_directory"), directory: () => invoke<Directory>("shares_directory"),
list: (noteId) => invoke<NoteShare[]>("shares_list", { noteId }), list: (noteId) => invoke<NoteShare[]>("shares_list", { noteId }),
share: (noteId, userId, permission) => invoke<NoteShare[]>("shares_share", { noteId, userId, permission }), share: (noteId, target, permission) =>
invoke<NoteShare[]>("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<NoteShare[]>("shares_unshare", { noteId, shareId }), unshare: (noteId, shareId) => invoke<NoteShare[]>("shares_unshare", { noteId, shareId }),
}, },
}; };
+23 -5
View File
@@ -55,11 +55,29 @@ export interface Member {
email: string; 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"; export type SharePermission = "view" | "edit";
/** One share of a note: exactly one of `member` and `group` is set. */
export interface NoteShare { export interface NoteShare {
id: string; id: string;
member: Member; member: Member | null;
group: ShareGroup | null;
permission: SharePermission; permission: SharePermission;
created_at: string | null; created_at: string | null;
} }
@@ -135,11 +153,11 @@ export interface NotesRepo {
} }
export interface SharesRepo { export interface SharesRepo {
/** Everyone on the instance but you. */ /** Everyone on the instance but you, and every group. */
directory(): Promise<Member[]>; directory(): Promise<Directory>;
list(noteId: string): Promise<NoteShare[]>; list(noteId: string): Promise<NoteShare[]>;
/** 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. */
share(noteId: string, userId: string, permission: SharePermission): Promise<NoteShare[]>; share(noteId: string, target: ShareTarget, permission: SharePermission): Promise<NoteShare[]>;
unshare(noteId: string, shareId: string): Promise<NoteShare[]>; unshare(noteId: string, shareId: string): Promise<NoteShare[]>;
} }
+4 -4
View File
@@ -14,8 +14,8 @@ import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config"; import type { PublicConfig } from "../stores/config";
import type { import type {
DeviceToken, DeviceToken,
Directory,
ImportResult, ImportResult,
Member,
NoteChanges, NoteChanges,
NoteShare, NoteShare,
NoteCreateInput, NoteCreateInput,
@@ -120,10 +120,10 @@ export const rest: Repo = {
}, },
shares: { shares: {
directory: async () => (await api.get<{ members: Member[] }>("/api/users/directory")).members, directory: () => api.get<Directory>("/api/users/directory"),
list: async (noteId) => (await api.get<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`)).shares, list: async (noteId) => (await api.get<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`)).shares,
share: async (noteId, userId, permission) => share: async (noteId, target, permission) =>
(await api.post<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`, { user_id: userId, permission })).shares, (await api.post<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`, { ...target, permission })).shares,
unshare: async (noteId, shareId) => unshare: async (noteId, shareId) =>
(await api.del<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares/${shareId}`)).shares, (await api.del<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares/${shareId}`)).shares,
}, },
+228
View File
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue";
import Icon from "./Icon.vue";
// Admin: groups of people a note can be shared with in one go (#5177). A group is
// the instance's; anyone can share with it, and a note shared with it reaches whoever
// is in it at the time, so adding or removing someone here changes what they see.
interface Person {
id: string;
display_name: string;
email: string;
}
interface Group {
id: string;
name: string;
members: Person[];
}
const ui = useUiStore();
const groups = ref<Group[]>([]);
const accounts = ref<Person[]>([]);
const loading = ref(true);
const error = ref("");
const name = ref("");
const creating = ref(false);
const busy = ref("");
// Which group's "Add someone" picker holds which account id.
const picks = ref<Record<string, string>>({});
const byName = (a: Group, b: Group) => a.name.localeCompare(b.name);
function outside(group: Group): Person[] {
const inside = new Set(group.members.map((m) => m.id));
return accounts.value.filter((a) => !inside.has(a.id));
}
const empty = computed(() => !loading.value && !groups.value.length);
async function load() {
error.value = "";
try {
const [g, a] = await Promise.all([
api.get<{ groups: Group[] }>("/api/groups"),
api.get<{ accounts: Person[] }>("/api/accounts"),
]);
groups.value = g.groups;
accounts.value = a.accounts;
} catch (e) {
error.value = errorMessage(e, "Couldn't load groups.");
} finally {
loading.value = false;
}
}
function replace(updated: Group) {
groups.value = groups.value.map((g) => (g.id === updated.id ? updated : g)).sort(byName);
}
async function act(id: string, fn: () => Promise<void>, fallback: string) {
busy.value = id;
try {
await fn();
} catch (e) {
ui.showToast(errorMessage(e, fallback));
} finally {
busy.value = "";
}
}
async function create() {
if (!name.value.trim()) return;
creating.value = true;
error.value = "";
try {
const made = await api.post<Group>("/api/groups", { name: name.value.trim() });
groups.value = [...groups.value, made].sort(byName);
name.value = "";
} catch (e) {
error.value = errorMessage(e, "Couldn't create the group.");
} finally {
creating.value = false;
}
}
function rename(group: Group) {
const next = window.prompt("Rename the group", group.name)?.trim();
if (!next || next === group.name) return;
void act(
group.id,
async () => replace(await api.patch<Group>(`/api/groups/${group.id}`, { name: next })),
"Couldn't rename it.",
);
}
function remove(group: Group) {
if (!window.confirm(`Delete "${group.name}"? Notes shared with it stop being shared with its members.`)) return;
void act(
group.id,
async () => {
await api.del(`/api/groups/${group.id}`);
groups.value = groups.value.filter((g) => g.id !== group.id);
},
"Couldn't delete it.",
);
}
function addMember(group: Group) {
const userId = picks.value[group.id];
if (!userId) return;
void act(
group.id,
async () => {
replace(await api.post<Group>(`/api/groups/${group.id}/members`, { user_id: userId }));
picks.value[group.id] = "";
},
"Couldn't add them.",
);
}
function removeMember(group: Group, person: Person) {
void act(
group.id,
async () => replace(await api.del<Group>(`/api/groups/${group.id}/members/${person.id}`)),
"Couldn't remove them.",
);
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="flex flex-col gap-5">
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Groups</h2>
<form class="flex flex-wrap items-end gap-3" @submit.prevent="create">
<BaseInput id="group-name" v-model="name" label="New group" placeholder="Family" class="min-w-48 flex-1" />
<BaseButton type="submit" :loading="creating" :disabled="!name.trim()">Create group</BaseButton>
</form>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div v-if="loading" class="py-6 text-center text-sm text-neutral-400">Loading…</div>
<p v-else-if="empty" class="text-sm text-neutral-400">No groups yet.</p>
<ul v-else class="flex flex-col gap-2">
<li
v-for="g in groups"
:key="g.id"
class="flex flex-col gap-3 rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ g.name }}</p>
<p class="text-xs text-neutral-400">
{{ g.members.length }} {{ g.members.length === 1 ? "person" : "people" }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800"
:disabled="busy === g.id"
@click="rename(g)"
>
Rename
</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:text-red-400 dark:hover:bg-red-950/40"
:disabled="busy === g.id"
@click="remove(g)"
>
Delete
</button>
</div>
</div>
<ul v-if="g.members.length" class="flex flex-wrap gap-1.5">
<li
v-for="m in g.members"
:key="m.id"
class="flex items-center gap-1 rounded-full bg-neutral-100 py-0.5 pl-2.5 pr-1 text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-200"
:title="m.email"
>
{{ m.display_name || m.email }}
<button
type="button"
class="rounded-full p-0.5 text-neutral-400 hover:text-red-500 disabled:opacity-50"
:aria-label="`Remove ${m.display_name || m.email} from ${g.name}`"
:disabled="busy === g.id"
@click="removeMember(g, m)"
>
<Icon name="close" />
</button>
</li>
</ul>
<form v-if="outside(g).length" class="flex flex-wrap items-center gap-2" @submit.prevent="addMember(g)">
<select
v-model="picks[g.id]"
class="min-w-0 flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800"
:aria-label="`Add someone to ${g.name}`"
:disabled="busy === g.id"
>
<option :value="undefined" disabled>Add someone…</option>
<option v-for="a in outside(g)" :key="a.id" :value="a.id">{{ a.display_name }} ({{ a.email }})</option>
</select>
<button
type="submit"
class="rounded-md border border-neutral-300 px-2.5 py-1.5 text-xs text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800"
:disabled="busy === g.id || !picks[g.id]"
>
Add
</button>
</form>
</li>
</ul>
</section>
</template>
+54 -22
View File
@@ -1,15 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { repo } from "../adapters"; import { repo } from "../adapters";
import type { Member, NoteShare, SharePermission } from "../adapters/repo"; import type { Directory, NoteShare, SharePermission, ShareTarget } from "../adapters/repo";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { useNotesStore } from "../stores/notes"; import { useNotesStore } from "../stores/notes";
import BaseModal from "./BaseModal.vue"; import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
// Share a note with other people on this instance (#5174). The owner picks a member // Share a note with other people on this instance (#5174). The owner picks a member,
// from the directory and says whether they may only read it or also change its text. // or a group the admin made (#5177), and says whether they may only read it or also
// Only the owner opens this; the server refuses everyone else regardless. // change its text. Only the owner opens this; the server refuses everyone else
// regardless.
const props = defineProps<{ noteId: string }>(); const props = defineProps<{ noteId: string }>();
const emit = defineEmits<{ (e: "close"): void }>(); const emit = defineEmits<{ (e: "close"): void }>();
@@ -20,26 +21,47 @@ const PERMISSIONS: { value: SharePermission; label: string }[] = [
{ value: "edit", label: "Can edit" }, { value: "edit", label: "Can edit" },
]; ];
const members = ref<Member[]>([]); const directory = ref<Directory>({ members: [], groups: [] });
const shares = ref<NoteShare[]>([]); const shares = ref<NoteShare[]>([]);
const loading = ref(true); const loading = ref(true);
const loadError = ref(""); const loadError = ref("");
const error = ref(""); const error = ref("");
const busy = ref(false); const busy = ref(false);
// "u:<id>" for a person, "g:<id>" for a group: one picker lists both.
const pick = ref(""); const pick = ref("");
const permission = ref<SharePermission>("view"); const permission = ref<SharePermission>("view");
/** The people it isn't shared with yet. */ /** The people and groups it isn't shared with yet. */
const available = computed(() => { const availablePeople = computed(() => {
const taken = new Set(shares.value.map((s) => s.member.id)); const taken = new Set(shares.value.map((s) => s.member?.id));
return members.value.filter((m) => !taken.has(m.id)); return directory.value.members.filter((m) => !taken.has(m.id));
}); });
const availableGroups = computed(() => {
const taken = new Set(shares.value.map((s) => s.group?.id));
return directory.value.groups.filter((g) => !taken.has(g.id));
});
const anyone = computed(() => directory.value.members.length + directory.value.groups.length > 0);
const anyoneLeft = computed(() => availablePeople.value.length + availableGroups.value.length > 0);
function targetOf(share: NoteShare): ShareTarget {
return share.group ? { group_id: share.group.id } : { user_id: share.member?.id ?? "" };
}
function nameOf(share: NoteShare): string {
return share.group?.name ?? share.member?.display_name ?? "";
}
function detailOf(share: NoteShare): string {
if (!share.group) return share.member?.email ?? "";
const n = share.group.member_count;
return `Group · ${n} ${n === 1 ? "person" : "people"}`;
}
async function load() { async function load() {
loading.value = true; loading.value = true;
loadError.value = ""; loadError.value = "";
try { try {
[members.value, shares.value] = await Promise.all([repo.shares.directory(), repo.shares.list(props.noteId)]); [directory.value, shares.value] = await Promise.all([repo.shares.directory(), repo.shares.list(props.noteId)]);
} catch (e) { } catch (e) {
loadError.value = errorMessage(e, "Couldn't load who this is shared with."); loadError.value = errorMessage(e, "Couldn't load who this is shared with.");
} finally { } finally {
@@ -62,13 +84,14 @@ async function run(fn: () => Promise<NoteShare[]>, fallback: string) {
async function add() { async function add() {
if (!pick.value) return; if (!pick.value) return;
const userId = pick.value; const id = pick.value.slice(2);
await run(() => repo.shares.share(props.noteId, userId, permission.value), "Couldn't share the note."); const target: ShareTarget = pick.value.startsWith("g:") ? { group_id: id } : { user_id: id };
await run(() => repo.shares.share(props.noteId, target, permission.value), "Couldn't share the note.");
if (!error.value) pick.value = ""; if (!error.value) pick.value = "";
} }
function change(share: NoteShare, next: SharePermission) { function change(share: NoteShare, next: SharePermission) {
void run(() => repo.shares.share(props.noteId, share.member.id, next), "Couldn't change that."); void run(() => repo.shares.share(props.noteId, targetOf(share), next), "Couldn't change that.");
} }
function remove(share: NoteShare) { function remove(share: NoteShare) {
@@ -103,14 +126,23 @@ onMounted(load);
</div> </div>
<template v-else> <template v-else>
<p v-if="!members.length" class="text-sm text-neutral-500 dark:text-neutral-400"> <p v-if="!anyone" class="text-sm text-neutral-500 dark:text-neutral-400">
No one else has an account here yet. An admin can invite people from Settings. No one else has an account here yet. An admin can invite people from Settings.
</p> </p>
<form v-else-if="available.length" class="flex flex-wrap items-center gap-2" @submit.prevent="add"> <form v-else-if="anyoneLeft" class="flex flex-wrap items-center gap-2" @submit.prevent="add">
<select v-model="pick" :class="[selectClass, 'min-w-0 flex-1']" aria-label="Person" :disabled="busy"> <select v-model="pick" :class="[selectClass, 'min-w-0 flex-1']" aria-label="Person or group" :disabled="busy">
<option value="" disabled>Choose someone…</option> <option value="" disabled>Choose someone…</option>
<option v-for="m in available" :key="m.id" :value="m.id">{{ m.display_name }} ({{ m.email }})</option> <optgroup v-if="availablePeople.length" label="People">
<option v-for="m in availablePeople" :key="m.id" :value="`u:${m.id}`">
{{ m.display_name }} ({{ m.email }})
</option>
</optgroup>
<optgroup v-if="availableGroups.length" label="Groups">
<option v-for="g in availableGroups" :key="g.id" :value="`g:${g.id}`">
{{ g.name }} ({{ g.member_count }})
</option>
</optgroup>
</select> </select>
<select v-model="permission" :class="selectClass" aria-label="Permission" :disabled="busy"> <select v-model="permission" :class="selectClass" aria-label="Permission" :disabled="busy">
<option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option> <option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option>
@@ -132,13 +164,13 @@ onMounted(load);
<ul v-else class="flex flex-col gap-1"> <ul v-else class="flex flex-col gap-1">
<li v-for="s in shares" :key="s.id" class="flex items-center gap-2 rounded-lg px-1 py-1"> <li v-for="s in shares" :key="s.id" class="flex items-center gap-2 rounded-lg px-1 py-1">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm text-neutral-800 dark:text-neutral-100">{{ s.member.display_name }}</p> <p class="truncate text-sm text-neutral-800 dark:text-neutral-100">{{ nameOf(s) }}</p>
<p class="truncate text-xs text-neutral-400">{{ s.member.email }}</p> <p class="truncate text-xs text-neutral-400">{{ detailOf(s) }}</p>
</div> </div>
<select <select
:value="s.permission" :value="s.permission"
:class="selectClass" :class="selectClass"
:aria-label="`Permission for ${s.member.display_name}`" :aria-label="`Permission for ${nameOf(s)}`"
:disabled="busy" :disabled="busy"
@change="change(s, ($event.target as HTMLSelectElement).value as SharePermission)" @change="change(s, ($event.target as HTMLSelectElement).value as SharePermission)"
> >
@@ -147,8 +179,8 @@ onMounted(load);
<button <button
type="button" type="button"
class="icon-btn" class="icon-btn"
:title="`Stop sharing with ${s.member.display_name}`" :title="`Stop sharing with ${nameOf(s)}`"
:aria-label="`Stop sharing with ${s.member.display_name}`" :aria-label="`Stop sharing with ${nameOf(s)}`"
:disabled="busy" :disabled="busy"
@click="remove(s)" @click="remove(s)"
> >
+3 -1
View File
@@ -5,6 +5,7 @@ import { useConfigStore } from "../stores/config";
import BaseButton from "../components/BaseButton.vue"; import BaseButton from "../components/BaseButton.vue";
import InviteList from "../components/InviteList.vue"; import InviteList from "../components/InviteList.vue";
import AccountList from "../components/AccountList.vue"; import AccountList from "../components/AccountList.vue";
import GroupList from "../components/GroupList.vue";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
@@ -227,9 +228,10 @@ onMounted(load);
</div> </div>
</form> </form>
<!-- Outside the settings form: each invite and account action saves on its own, <!-- Outside the settings form: each invite, account and group action saves on its own,
and the form's Save button has nothing to do with them. --> and the form's Save button has nothing to do with them. -->
<InviteList v-if="items.length" class="mt-10" /> <InviteList v-if="items.length" class="mt-10" />
<AccountList v-if="items.length" class="mt-10" /> <AccountList v-if="items.length" class="mt-10" />
<GroupList v-if="items.length" class="mt-10" />
</div> </div>
</template> </template>