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
+54 -22
View File
@@ -1,15 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
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 { useNotesStore } from "../stores/notes";
import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.vue";
// 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.
// Only the owner opens this; the server refuses everyone else regardless.
// Share a note with other people on this instance (#5174). The owner picks a member,
// or a group the admin made (#5177), and says whether they may only read it or also
// change its text. Only the owner opens this; the server refuses everyone else
// regardless.
const props = defineProps<{ noteId: string }>();
const emit = defineEmits<{ (e: "close"): void }>();
@@ -20,26 +21,47 @@ const PERMISSIONS: { value: SharePermission; label: string }[] = [
{ value: "edit", label: "Can edit" },
];
const members = ref<Member[]>([]);
const directory = ref<Directory>({ members: [], groups: [] });
const shares = ref<NoteShare[]>([]);
const loading = ref(true);
const loadError = ref("");
const error = ref("");
const busy = ref(false);
// "u:<id>" for a person, "g:<id>" for a group: one picker lists both.
const pick = ref("");
const permission = ref<SharePermission>("view");
/** The people it isn't shared with yet. */
const available = computed(() => {
const taken = new Set(shares.value.map((s) => s.member.id));
return members.value.filter((m) => !taken.has(m.id));
/** The people and groups it isn't shared with yet. */
const availablePeople = computed(() => {
const taken = new Set(shares.value.map((s) => s.member?.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() {
loading.value = true;
loadError.value = "";
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) {
loadError.value = errorMessage(e, "Couldn't load who this is shared with.");
} finally {
@@ -62,13 +84,14 @@ async function run(fn: () => Promise<NoteShare[]>, fallback: string) {
async function add() {
if (!pick.value) return;
const userId = pick.value;
await run(() => repo.shares.share(props.noteId, userId, permission.value), "Couldn't share the note.");
const id = pick.value.slice(2);
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 = "";
}
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) {
@@ -103,14 +126,23 @@ onMounted(load);
</div>
<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.
</p>
<form v-else-if="available.length" 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">
<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 or group" :disabled="busy">
<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 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>
@@ -132,13 +164,13 @@ onMounted(load);
<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">
<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-xs text-neutral-400">{{ s.member.email }}</p>
<p class="truncate text-sm text-neutral-800 dark:text-neutral-100">{{ nameOf(s) }}</p>
<p class="truncate text-xs text-neutral-400">{{ detailOf(s) }}</p>
</div>
<select
:value="s.permission"
:class="selectClass"
:aria-label="`Permission for ${s.member.display_name}`"
:aria-label="`Permission for ${nameOf(s)}`"
:disabled="busy"
@change="change(s, ($event.target as HTMLSelectElement).value as SharePermission)"
>
@@ -147,8 +179,8 @@ onMounted(load);
<button
type="button"
class="icon-btn"
:title="`Stop sharing with ${s.member.display_name}`"
:aria-label="`Stop sharing with ${s.member.display_name}`"
:title="`Stop sharing with ${nameOf(s)}`"
:aria-label="`Stop sharing with ${nameOf(s)}`"
:disabled="busy"
@click="remove(s)"
>