sharing: share a note from the web, at view or edit, with anyone on the instance
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m6s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m33s

The ACL has gated every read since M0, but nothing could write a share.

Server:
- shares_api: GET /api/users/directory (everyone but you, signed-in only),
  GET/POST /api/notes/<id>/shares and DELETE …/shares/<share_id>, owner
  only. Sharing again with the same person changes the permission
  (ON CONFLICT on the new unique index).
- acl.visible_to_user takes permission=; granted_to and shared_ids feed
  the serializer.
- Edit covers body and checklist (_get_editable). Everything else stays
  _get_owned. A view share's write is a 404 like a stranger's (#1984). An
  editor's PATCH naming anything but body is a 403.
- Serialized notes carry permission, shared and shared_by. A recipient
  never gets the owner's labels, and a #tag an editor types files under
  the owner's (it always went to note.owner_id).
- ?shared=with_me, also allowed in saved views. Trash and reminders are the
  owner's. purge_note drops the note's shares.
- Migration 0034: one share per note and person (and per group), permission
  limited to view and edit, an index for "shared with me".

Web:
- ShareDialog (one, mounted by the shell): pick a member, Can view or Can
  edit, change or remove existing shares, with loading, error and empty
  states.
- Card: "Shared by X" or "Shared" chip; owner-only actions and reminder
  buttons hidden for recipients; checkboxes inert at view.
- Editor: read-only at view; text and checklist only at edit; Share button
  for the owner.
- FilterBar: Shared with me. Repo seam gains `shares`; the offline desktop
  shows none of it (#5175 brings sharing there).

Tests: owner, recipient and stranger across reads, every write at view and
edit, tag filing, unshare, trash, delete and validation; web unit tests for
the facet and permission helpers. #5174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:01:53 -04:00
co-authored by Claude Opus 5.5
parent f100e5ef85
commit f53d377766
24 changed files with 911 additions and 50 deletions
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { repo } from "../adapters";
import type { Member, NoteShare, SharePermission } 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.
const props = defineProps<{ noteId: string }>();
const emit = defineEmits<{ (e: "close"): void }>();
const notes = useNotesStore();
const PERMISSIONS: { value: SharePermission; label: string }[] = [
{ value: "view", label: "Can view" },
{ value: "edit", label: "Can edit" },
];
const members = ref<Member[]>([]);
const shares = ref<NoteShare[]>([]);
const loading = ref(true);
const loadError = ref("");
const error = ref("");
const busy = ref(false);
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));
});
async function load() {
loading.value = true;
loadError.value = "";
try {
[members.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 {
loading.value = false;
}
}
async function run(fn: () => Promise<NoteShare[]>, fallback: string) {
busy.value = true;
error.value = "";
try {
shares.value = await fn();
notes.setShared(props.noteId, shares.value.length > 0);
} catch (e) {
error.value = errorMessage(e, fallback);
} finally {
busy.value = false;
}
}
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.");
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.");
}
function remove(share: NoteShare) {
void run(() => repo.shares.unshare(props.noteId, share.id), "Couldn't stop sharing.");
}
const selectClass =
"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 disabled:opacity-60 dark:border-neutral-700 dark:bg-neutral-800";
onMounted(load);
</script>
<template>
<BaseModal panel-class="w-full max-w-md shadow-xl" @close="emit('close')">
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Share note</h2>
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
</div>
<div class="flex flex-col gap-4 p-4">
<p v-if="loading" class="py-4 text-center text-sm text-neutral-400">Loading…</p>
<div v-else-if="loadError" class="py-4 text-center">
<p class="text-sm text-red-600 dark:text-red-400">{{ loadError }}</p>
<button
type="button"
class="mt-2 rounded-md border border-neutral-300 px-3 py-1 text-sm hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800"
@click="load"
>
Retry
</button>
</div>
<template v-else>
<p v-if="!members.length" 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">
<option value="" disabled>Choose someone…</option>
<option v-for="m in available" :key="m.id" :value="m.id">{{ m.display_name }} ({{ m.email }})</option>
</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>
</select>
<button
type="submit"
class="rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60"
:disabled="busy || !pick"
>
Share
</button>
</form>
<p v-if="error" role="alert" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div class="flex flex-col gap-1">
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Shared with</p>
<p v-if="!shares.length" class="text-sm text-neutral-400">Only you.</p>
<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>
</div>
<select
:value="s.permission"
:class="selectClass"
:aria-label="`Permission for ${s.member.display_name}`"
:disabled="busy"
@change="change(s, ($event.target as HTMLSelectElement).value as SharePermission)"
>
<option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
<button
type="button"
class="icon-btn"
:title="`Stop sharing with ${s.member.display_name}`"
:aria-label="`Stop sharing with ${s.member.display_name}`"
:disabled="busy"
@click="remove(s)"
>
<Icon name="close" />
</button>
</li>
</ul>
</div>
</template>
</div>
</BaseModal>
</template>